explorbot 0.4.4 → 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.
Files changed (51) hide show
  1. package/boat/prima/src/prima.ts +8 -3
  2. package/dist/boat/prima/src/prima.js +10 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/fisherman/tools.js +7 -1
  5. package/dist/src/ai/fisherman.js +2 -1
  6. package/dist/src/ai/pilot.d.ts +0 -1
  7. package/dist/src/ai/pilot.js +8 -24
  8. package/dist/src/ai/planner.d.ts +4 -0
  9. package/dist/src/ai/planner.js +28 -0
  10. package/dist/src/ai/provider.js +3 -1
  11. package/dist/src/ai/rules.js +8 -7
  12. package/dist/src/ai/scout/tools.d.ts +17 -0
  13. package/dist/src/ai/scout/tools.js +130 -0
  14. package/dist/src/ai/scout.d.ts +21 -0
  15. package/dist/src/ai/scout.js +150 -0
  16. package/dist/src/ai/tools.js +56 -30
  17. package/dist/src/application-spec.d.ts +3 -0
  18. package/dist/src/application-spec.js +21 -5
  19. package/dist/src/config.d.ts +6 -1
  20. package/dist/src/explorbot.d.ts +3 -0
  21. package/dist/src/explorbot.js +33 -0
  22. package/dist/src/knowledge-tracker.d.ts +1 -0
  23. package/dist/src/knowledge-tracker.js +3 -0
  24. package/dist/src/utils/aria-ref.d.ts +16 -0
  25. package/dist/src/utils/aria-ref.js +47 -0
  26. package/dist/src/utils/aria.js +3 -3
  27. package/dist/src/utils/web-annotate.js +3 -15
  28. package/dist/src/utils/web-element.d.ts +0 -2
  29. package/dist/src/utils/web-element.js +0 -8
  30. package/docs/reference/configuration.md +28 -1
  31. package/docs/web-testing/agents.md +9 -1
  32. package/docs/web-testing/planner.md +5 -0
  33. package/docs/workflow/application-spec.md +4 -0
  34. package/package.json +1 -1
  35. package/src/ai/fisherman/tools.ts +8 -1
  36. package/src/ai/fisherman.ts +2 -1
  37. package/src/ai/pilot.ts +8 -25
  38. package/src/ai/planner.ts +33 -0
  39. package/src/ai/provider.ts +2 -1
  40. package/src/ai/rules.ts +8 -7
  41. package/src/ai/scout/tools.ts +150 -0
  42. package/src/ai/scout.ts +173 -0
  43. package/src/ai/tools.ts +69 -36
  44. package/src/application-spec.ts +22 -4
  45. package/src/config.ts +7 -0
  46. package/src/explorbot.ts +36 -0
  47. package/src/knowledge-tracker.ts +4 -0
  48. package/src/utils/aria-ref.ts +61 -0
  49. package/src/utils/aria.ts +3 -3
  50. package/src/utils/web-annotate.ts +3 -15
  51. package/src/utils/web-element.ts +0 -9
@@ -7,11 +7,13 @@ import type { RequestStore } from '../../api/request-store.ts';
7
7
  import { extractEndpointDefinition } from '../../api/spec-reader.ts';
8
8
  import type { Test } from '../../test-plan.ts';
9
9
  import { tag } from '../../utils/logger.ts';
10
+ import { truncate } from '../../utils/strings.ts';
10
11
  import { isDynamicSegment } from '../../utils/url-matcher.ts';
11
12
  import type { Fisherman } from '../fisherman.ts';
12
13
  import type { RequestHaul } from './request-haul.ts';
13
14
 
14
15
  const BODY_PREVIEW_LIMIT = 2000;
16
+ const READS_IN_ANSWER = 3;
15
17
 
16
18
  export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string; readOnly?: boolean }) {
17
19
  const readOnly = opts.readOnly === true;
@@ -242,7 +244,7 @@ export function createAskApiTool(fisherman: Fisherman | null, task: Test) {
242
244
  }
243
245
 
244
246
  task.addNote(`Asked API: ${question} — ${result.summary}`);
245
- tag('success').log(`Ask API: ${result.summary}`);
247
+ tag('success').log(`Ask API: ${truncate(result.summary, 200)}`);
246
248
  return { answered: true, answer: result.summary };
247
249
  },
248
250
  }),
@@ -285,6 +287,11 @@ function synthesizeResult(haul: RequestHaul, declaredDone: boolean, readOnly: bo
285
287
  succeeded = haul.successfulReads();
286
288
  successLabel = 'successful reads';
287
289
  }
290
+ if (readOnly && succeeded.length > 0) {
291
+ const bodies = succeeded.slice(-READS_IN_ANSWER).map((read) => `${read.toEndpoint()} → ${read.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT)}`);
292
+ return { success: true, summary: bodies.join('\n\n'), created: [], failed: [] };
293
+ }
294
+
288
295
  let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
289
296
  const lastFailure = failures[failures.length - 1];
290
297
  if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
@@ -3,6 +3,7 @@ import type { ApiClient } from '../api/api-client.ts';
3
3
  import { type EndpointFamily, type RequestStore, isFailedRequest } from '../api/request-store.ts';
4
4
  import { listAllEndpoints } from '../api/spec-reader.ts';
5
5
  import { createDebug, tag } from '../utils/logger.ts';
6
+ import { truncate } from '../utils/strings.ts';
6
7
 
7
8
  const debugLog = createDebug('explorbot:fisherman');
8
9
  import { loop } from '../utils/loop.ts';
@@ -137,7 +138,7 @@ export class Fisherman implements Agent {
137
138
  await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
138
139
 
139
140
  const result = getResult();
140
- tag('info').log(`Fisherman answer: ${result.summary}`);
141
+ tag('info').log(`Fisherman answer: ${truncate(result.summary, 200)}`);
141
142
  return result;
142
143
  }
143
144
 
package/src/ai/pilot.ts CHANGED
@@ -363,8 +363,6 @@ export class Pilot implements Agent {
363
363
  return dedent`
364
364
  SCENARIO: ${task.scenario}
365
365
 
366
- ${this.buildDeletionScope(task)}
367
-
368
366
  EXPECTED RESULTS (milestones):
369
367
  ${task.expected.map((e) => `- ${e}`).join('\n')}
370
368
  `;
@@ -372,20 +370,22 @@ export class Pilot implements Agent {
372
370
 
373
371
  private buildResetSystemPrompt(task: Test): string {
374
372
  return dedent`
375
- You are Pilot — decide whether a reset is legitimate. Reset is DESTRUCTIVE: it abandons this
376
- iteration's work, but server-side side effects (records created, forms submitted) persist.
377
- Unnecessary resets create duplicate data and infinite loops.
373
+ You are Pilot — decide whether a reset is legitimate. Reset only re-navigates to the start URL:
374
+ it writes nothing, though it abandons this iteration's work and server-side side effects persist.
375
+ The hazard is the tester REDOING a completed flow afterwards — duplicate data and infinite loops.
378
376
 
379
377
  ${this.buildSharedEvidenceRules()}
380
378
 
381
379
  DECISION:
382
- - "allow": current page cannot host the scenario, irrecoverable error, or no path back.
383
- - "continue": prior action already succeeded (URL changed, record visible, confirmation shown) — verify/finish instead. Or scenario goal may already be met; instruct tester to verify the actual outcome rather than redo. Provide guidance.
380
+ - "allow": current page cannot host the scenario, irrecoverable error, no path back, or an
381
+ expectation requires the outcome to survive a reload or a return to the start page and no
382
+ reset has been taken yet this run — there the reset IS the check, not a redo.
383
+ - "continue": the outcome the scenario needs is already observable on the CURRENT page — verify/finish instead. Provide guidance.
384
384
  - "fail": resetCount >= 2 and underlying situation hasn't changed; same flow tried twice with same failure mode.
385
385
  - "skipped": feature doesn't exist on this app or prerequisites can't be met.
386
386
 
387
387
  PRIORITY:
388
- 1) Successful side effects in session_log → almost never allow reset.
388
+ 1) Successful side effects in session_log → allow reset only to re-observe them, never to repeat them.
389
389
  2) resetCount — each prior reset raises the bar.
390
390
  3) Tester's stated reason — weigh against evidence, don't trust blindly.
391
391
 
@@ -1102,23 +1102,6 @@ export class Pilot implements Agent {
1102
1102
  .join('\n\n');
1103
1103
  }
1104
1104
 
1105
- private buildDeletionScope(task: Test): string {
1106
- const deletableItems = task.plan
1107
- ? task.plan
1108
- .listTests()
1109
- .filter((t) => t.isSuccessful && t.sessionName)
1110
- .map((t) => t.sessionName!)
1111
- : [];
1112
- const scenarioLower = task.scenario.toLowerCase();
1113
- if (deletableItems.length > 0) {
1114
- return `For deletion scenarios, items can only be deleted if their title contains: ${deletableItems.join(', ')}`;
1115
- }
1116
- if (scenarioLower.includes('delete') || scenarioLower.includes('remove')) {
1117
- return 'No items available for deletion — test should create an item first';
1118
- }
1119
- return '';
1120
- }
1121
-
1122
1105
  private getSystemPrompt(task: Test, initialState: ActionResult): string {
1123
1106
  const interactive = isInteractive();
1124
1107
  const stepsText = task.plannedSteps.length > 0 ? task.plannedSteps.map((s, i) => `${i + 1}. ${s}`).join('\n') : 'No planned steps';
package/src/ai/planner.ts CHANGED
@@ -25,6 +25,7 @@ import { POSSIBLE_SECTIONS, type Researcher } from './researcher.ts';
25
25
  import { findSimilarStateHash } from './researcher/cache.ts';
26
26
  import { hasFocusedSection } from './researcher/focus.ts';
27
27
  import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
28
+ import type { Scout } from './scout.ts';
28
29
 
29
30
  const debugLog = createDebug('explorbot:planner');
30
31
 
@@ -63,6 +64,7 @@ export class Planner extends PlannerBase implements Agent {
63
64
  private lastSuite: Suite | null = null;
64
65
  researcher: Researcher;
65
66
  private fisherman: Fisherman | null = null;
67
+ private scout: Scout | null = null;
66
68
 
67
69
  constructor(deps: AgentDeps, researcher: Researcher) {
68
70
  super();
@@ -78,10 +80,19 @@ export class Planner extends PlannerBase implements Agent {
78
80
  this.fisherman = fisherman;
79
81
  }
80
82
 
83
+ setScout(scout: Scout): void {
84
+ this.scout = scout;
85
+ }
86
+
81
87
  private get sectionOrder(): string[] {
82
88
  return ConfigParser.getInstance().getConfig().ai?.agents?.researcher?.sections || Object.keys(POSSIBLE_SECTIONS);
83
89
  }
84
90
 
91
+ private get docsWeight(): number {
92
+ const value = ConfigParser.getInstance().getConfig().ai?.agents?.planner?.docsWeight ?? 70;
93
+ return Math.max(0, Math.min(100, value));
94
+ }
95
+
85
96
  private getDefaultStartUrl(state: { url: string; fullUrl?: string }): string {
86
97
  return state.fullUrl || state.url;
87
98
  }
@@ -328,6 +339,7 @@ export class Planner extends PlannerBase implements Agent {
328
339
  const conversation = new Conversation([], model);
329
340
  conversation.autoTrimTag('page_research', 20000);
330
341
  conversation.autoTrimTag('tested_scenarios', 10000);
342
+ conversation.autoTrimTag('docs_context', 8000);
331
343
 
332
344
  conversation.addUserText(this.getSystemMessage(feature));
333
345
 
@@ -385,6 +397,11 @@ export class Planner extends PlannerBase implements Agent {
385
397
  const research = await this.researcher.research(currentState || state, {
386
398
  deep: true,
387
399
  });
400
+
401
+ let docsPromise: Promise<string> | null = null;
402
+ if (this.scout && this.docsWeight > 0) {
403
+ docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
404
+ }
388
405
  let plannerResearch = mdq(research).query('code').replace('');
389
406
  plannerResearch = mdq(plannerResearch)
390
407
  .query('table')
@@ -419,6 +436,22 @@ export class Planner extends PlannerBase implements Agent {
419
436
  conversation.addUserText(applicationContext);
420
437
  }
421
438
 
439
+ if (docsPromise) {
440
+ const docs = await docsPromise;
441
+ if (docs) {
442
+ conversation.addUserText(dedent`
443
+ <docs_context>
444
+ Documentation retrieved from the collected corpus by the Scout agent.
445
+ Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
446
+
447
+ Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
448
+
449
+ ${docs}
450
+ </docs_context>
451
+ `);
452
+ }
453
+ }
454
+
422
455
  conversation.addUserText(dedent`
423
456
  ${this.buildApproach(style)}
424
457
 
@@ -409,7 +409,8 @@ export class Provider {
409
409
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
410
410
  promptLog(`Using model: ${modelName}`);
411
411
 
412
- const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
412
+ let toolsWithCommentary = tools;
413
+ if (!tools?.commentary && options.toolChoice !== 'required') toolsWithCommentary = { ...tools, commentary: createHarmonyChannelFallbackTool() };
413
414
  const toolNames = Object.keys(toolsWithCommentary || {});
414
415
  tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
415
416
  promptLog('Available tools:', toolNames);
package/src/ai/rules.ts CHANGED
@@ -8,8 +8,9 @@ const locatorPriorityRule = dedent`
8
8
 
9
9
  1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
10
10
  Use JSON format: { "role": "button", "text": "Login" }
11
- Copy role and text VERBATIM from the ARIA snapshot or UI map never guess the pair.
12
- If the element is absent from the snapshot, do not invent one; use text or CSS instead.
11
+ Copy role and text VERBATIM from the ARIA snapshot, UI map, or the page diff that
12
+ reported the element never guess the pair; a guessed role can silently match a
13
+ different element with the same text. If named nowhere, use text or CSS instead.
13
14
 
14
15
  2. Text locators (second choice) - exact visible text, use only when unique on the page
15
16
  Example: 'Login', 'Submit', 'Username'
@@ -238,10 +239,10 @@ export const unexpectedPopupRule = dedent`
238
239
  If buttons are disabled unexpectedly, check if a popup is blocking interaction or if required form fields are empty.
239
240
 
240
241
  Dismiss strategy (try in order):
241
- 1. I.clickXY(0, 0) — click outside the popup to close it
242
- 2. I.pressKey('Escape') — press Escape to dismiss
243
- 3. I.click('Cancel') — click Cancel button if present
244
- 4. I.click({ role: 'button', text: 'Close' }) click X/close button if present
242
+ 1. I.pressKey('Escape') — press Escape to dismiss
243
+ 2. I.click('Cancel') — click Cancel button if present
244
+ 3. I.click({ role: 'button', text: 'Close' }) — click X/close button if present
245
+ 4. I.clickXY(0, 0) via form() tool and check if page diff changed
245
246
  </unexpected_popup_rule>
246
247
  `;
247
248
 
@@ -335,7 +336,7 @@ export const actionRule = dedent`
335
336
  Prefer text/ARIA locators with context over complex CSS/XPath selectors.
336
337
  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.
337
338
  If locator doesn't work, try CSS or XPath locators.
338
- If nothing works, use I.clickXY(x, y) as last resort.
339
+ If nothing works, use visualClick() it locates the target in a screenshot before clicking it.
339
340
 
340
341
  When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
341
342
  Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
@@ -0,0 +1,150 @@
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.ts';
8
+ import { tag } from '../../utils/logger.ts';
9
+ import { loadMarkdownFiles } from '../../utils/markdown-files.ts';
10
+ import { readCaptainFile } from '../captain/file-tools.ts';
11
+
12
+ const MAX_FILES = 500;
13
+ const MAX_FINDINGS = 6000;
14
+
15
+ let cachedScanner: 'rg' | 'grep' | null = null;
16
+
17
+ export function loadScoutCorpus(dirs: string[]): ScoutCorpus {
18
+ const files: ScoutCorpusFile[] = [];
19
+ for (const dir of dirs) {
20
+ if (files.length >= MAX_FILES) {
21
+ tag('warning').log(`Scout corpus capped at ${MAX_FILES} files — remaining directories skipped`);
22
+ break;
23
+ }
24
+ for (const file of loadMarkdownFiles(dir, { recursive: true })) {
25
+ if (files.length >= MAX_FILES) break;
26
+ const entry: ScoutCorpusFile = { path: file.filePath };
27
+ if (typeof file.data.url === 'string') entry.url = file.data.url;
28
+ files.push(entry);
29
+ }
30
+ }
31
+ return { dirs, files, excludedPaths: [] };
32
+ }
33
+
34
+ export function excludeCorpusUrls(corpus: ScoutCorpus, urls: string[]): ScoutCorpus {
35
+ if (urls.length === 0) return corpus;
36
+
37
+ const excludedUrls = new Set(urls);
38
+ const files: ScoutCorpusFile[] = [];
39
+ const excludedPaths = [...corpus.excludedPaths];
40
+ for (const file of corpus.files) {
41
+ if (file.url && excludedUrls.has(file.url)) {
42
+ excludedPaths.push(file.path);
43
+ continue;
44
+ }
45
+ files.push(file);
46
+ }
47
+ return { dirs: corpus.dirs, files, excludedPaths };
48
+ }
49
+
50
+ export async function createScoutTools(corpus: ScoutCorpus) {
51
+ const scanner = await detectScanner();
52
+ const projectRoot = ConfigParser.getInstance().getProjectRoot();
53
+
54
+ let result = '';
55
+ let searchedOrRead = false;
56
+
57
+ const getResult = () => result;
58
+ const finishFromText = (text?: string) => {
59
+ if (text && searchedOrRead) result = text.slice(0, MAX_FINDINGS);
60
+ };
61
+
62
+ const files: Record<string, string> = {};
63
+ const readableFiles = new Set<string>();
64
+ for (const file of corpus.files) {
65
+ files[toPosix(file.path)] = readFileSync(file.path, 'utf8');
66
+ readableFiles.add(resolve(file.path));
67
+ }
68
+
69
+ const toolkit = await createBashTool({
70
+ destination: '/',
71
+ files,
72
+ maxOutputLength: 20000,
73
+ });
74
+
75
+ const bashExecute = toolkit.bash.execute;
76
+ const bash = {
77
+ ...toolkit.bash,
78
+ execute: async (input: { command: string }) => {
79
+ tag('step').log(`Scout: bash ${input.command}`);
80
+ searchedOrRead = true;
81
+ return bashExecute?.(input);
82
+ },
83
+ };
84
+
85
+ const tools: Record<string, any> = {
86
+ bash,
87
+ readFile: tool({
88
+ description: dedent`
89
+ Read one documentation file from the corpus.
90
+ Pass the exact path returned by a search result.
91
+ `,
92
+ inputSchema: z.object({
93
+ path: z.string().describe('File path from a search result'),
94
+ startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
95
+ endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
96
+ maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
97
+ }),
98
+ execute: async (input) => {
99
+ tag('step').log(`Scout: read ${input.path}`);
100
+ const output = readCaptainFile(projectRoot, input, corpus.dirs);
101
+ if (!output.success) return output;
102
+ const resolvedPath = resolve(projectRoot || process.cwd(), output.path);
103
+ if (!readableFiles.has(resolvedPath)) {
104
+ return { success: false, message: 'File is outside the Scout corpus' };
105
+ }
106
+ searchedOrRead = true;
107
+ return output;
108
+ },
109
+ }),
110
+ };
111
+
112
+ return { tools, scanner, getResult, finishFromText };
113
+ }
114
+
115
+ async function detectScanner(): Promise<'rg' | 'grep'> {
116
+ if (cachedScanner) return cachedScanner;
117
+ if (await binaryRuns('rg')) {
118
+ cachedScanner = 'rg';
119
+ return cachedScanner;
120
+ }
121
+ if (await binaryRuns('grep')) {
122
+ cachedScanner = 'grep';
123
+ return cachedScanner;
124
+ }
125
+ throw new Error('Scout requires ripgrep or grep on PATH — neither was found');
126
+ }
127
+
128
+ async function binaryRuns(binary: 'rg' | 'grep'): Promise<boolean> {
129
+ try {
130
+ const proc = Bun.spawn([binary, '--version'], { stdout: 'ignore', stderr: 'ignore', stdin: 'ignore' });
131
+ return (await proc.exited) === 0;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ function toPosix(path: string): string {
138
+ return path.split('\\').join('/');
139
+ }
140
+
141
+ export interface ScoutCorpus {
142
+ dirs: string[];
143
+ files: ScoutCorpusFile[];
144
+ excludedPaths: string[];
145
+ }
146
+
147
+ export interface ScoutCorpusFile {
148
+ path: string;
149
+ url?: string;
150
+ }
@@ -0,0 +1,173 @@
1
+ import dedent from 'dedent';
2
+ import { tag } from '../utils/logger.ts';
3
+ import { loop } from '../utils/loop.ts';
4
+ import type { Agent } from './agent.ts';
5
+ import type { Provider } from './provider.ts';
6
+ import { type ScoutCorpus, createScoutTools, excludeCorpusUrls } from './scout/tools.ts';
7
+
8
+ const MAX_ITERATIONS = 3;
9
+ const MAX_TOOL_ROUNDTRIPS = 5;
10
+ const CACHE_LIMIT = 40;
11
+ const URL_LISTING_LIMIT = 40;
12
+
13
+ export class Scout implements Agent {
14
+ emoji = '🔎';
15
+ private cache = new Map<string, string>();
16
+
17
+ constructor(
18
+ private provider: Provider,
19
+ private corpus: ScoutCorpus
20
+ ) {}
21
+
22
+ isAvailable(): boolean {
23
+ return this.corpus.files.length > 0;
24
+ }
25
+
26
+ async collectDocs(query: ScoutQuery): Promise<string> {
27
+ if (!this.isAvailable()) return '';
28
+
29
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
30
+ const cached = this.cache.get(cacheKey);
31
+ if (cached !== undefined) return cached;
32
+
33
+ const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls);
34
+ if (corpus.files.length === 0) return '';
35
+
36
+ const result = await this.runSession(corpus, query);
37
+ if (result === null) return '';
38
+
39
+ if (this.cache.size > CACHE_LIMIT) this.cache.clear();
40
+ this.cache.set(cacheKey, result);
41
+ return result;
42
+ }
43
+
44
+ private async runSession(corpus: ScoutCorpus, query: ScoutQuery): Promise<string | null> {
45
+ const { tools, scanner, getResult, finishFromText } = await createScoutTools(corpus);
46
+ const conversation = this.provider.startConversation(this.buildSystemPrompt(Object.keys(tools), corpus, query, scanner), 'scout', this.provider.getAgenticModel('scout'));
47
+ conversation.addUserText(this.buildTaskPrompt(query));
48
+
49
+ tag('info').log(`Scout: collecting documentation for ${query.feature || query.url || 'the current page'}`);
50
+
51
+ let failed = false;
52
+ await loop(
53
+ async ({ stop, iteration }) => {
54
+ const invokeResult = await this.provider.invokeConversation(conversation, tools, {
55
+ maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
56
+ agentName: 'scout',
57
+ });
58
+
59
+ if (!invokeResult?.toolExecutions?.length) {
60
+ finishFromText(invokeResult?.response?.text);
61
+ stop();
62
+ return;
63
+ }
64
+
65
+ if (iteration >= MAX_ITERATIONS) {
66
+ const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
67
+ finishFromText(final?.response?.text);
68
+ stop();
69
+ }
70
+ },
71
+ {
72
+ maxAttempts: MAX_ITERATIONS,
73
+ observability: { name: `scout: ${query.feature || query.url || 'docs'}`, agent: 'scout' },
74
+ catch: async ({ error, stop }) => {
75
+ failed = true;
76
+ tag('warning').log(`Scout error: ${(error as Error).message}`);
77
+ stop();
78
+ },
79
+ }
80
+ );
81
+
82
+ if (failed) return null;
83
+
84
+ const digest = getResult();
85
+ if (digest) {
86
+ const preview = digest.slice(0, 600);
87
+ const ellipsis = digest.length > 600 ? '…' : '';
88
+ tag('info').log(`Scout digest:\n${preview}${ellipsis}`);
89
+ }
90
+ return digest;
91
+ }
92
+
93
+ private buildSystemPrompt(toolNames: string[], corpus: ScoutCorpus, query: ScoutQuery, scanner: 'rg' | 'grep'): string {
94
+ const urls = corpus.files.map((file) => file.url).filter(Boolean) as string[];
95
+ const urlless = corpus.files.filter((file) => !file.url);
96
+ let pagesListing = '';
97
+ if (urls.length > 0) {
98
+ const listing = urls
99
+ .slice(0, URL_LISTING_LIMIT)
100
+ .map((url) => `- ${url}`)
101
+ .join('\n');
102
+ pagesListing = `Documented pages:\n${listing}`;
103
+ const remaining = urls.length - URL_LISTING_LIMIT;
104
+ if (remaining > 0) pagesListing += `\n…and ${remaining} more — find them with ${scanner}`;
105
+ }
106
+ if (urlless.length > 0) {
107
+ const listing = urlless
108
+ .slice(0, URL_LISTING_LIMIT)
109
+ .map((file) => `- ${toPosix(file.path)}`)
110
+ .join('\n');
111
+ pagesListing += `\nFiles with no page URL (hand-written docs):\n${listing}`;
112
+ }
113
+
114
+ const prompt = dedent`
115
+ You are Scout — a documentation retrieval agent. You find collected documentation relevant to a testing focus and report it for test planning.
116
+
117
+ You never see the application itself. The documentation corpus is your only source of truth.
118
+
119
+ CORPUS:
120
+ ${corpus.files.length} markdown files under:
121
+ - ${corpus.dirs.map(toPosix).join('\n- ')}
122
+ ${pagesListing}
123
+
124
+ These pages are already provided to the planner in full — do not re-report them:
125
+ ${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
126
+
127
+ AVAILABLE TOOLS:
128
+ ${toolNames.join(', ')}.
129
+ Use tool names exactly as listed. Do not invent aliases or combined names.
130
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
131
+
132
+ SCANNER:
133
+ ${scanner} is the search command. Scan the working directory through bash() — explore freely, pipelines, globs and repeated searches are fine. Read files with readFile().
134
+
135
+ WORKFLOW:
136
+ 1. Scan with ${scanner} using plain prose words from the focus — feature names, page purposes, capabilities
137
+ 2. Read the files whose hits look most relevant
138
+ 3. Report the digest as your final message — no tool call is needed to finish
139
+
140
+ RULES:
141
+ - Report only what the documentation states. Never fill gaps with assumptions about the application
142
+ - Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
143
+ - Name the page URL each item belongs to, so scenarios anchor to real routes
144
+ - Explore briefly: a few scans and reads are enough, then report
145
+ - A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
146
+ `;
147
+
148
+ const customPrompt = this.provider.getSystemPromptForAgent('scout');
149
+ if (customPrompt) return `${prompt}\n\n${customPrompt}`;
150
+ return prompt;
151
+ }
152
+
153
+ private buildTaskPrompt(query: ScoutQuery): string {
154
+ return dedent`
155
+ Page URL: ${query.url || 'Unknown'}
156
+ Page title: ${query.title || 'Unknown'}
157
+ Focus: ${query.feature || 'the page as a whole'}
158
+
159
+ Report the documented capabilities, states and transitions a test planner could turn into scenarios.
160
+ `;
161
+ }
162
+ }
163
+
164
+ function toPosix(path: string): string {
165
+ return path.split('\\').join('/');
166
+ }
167
+
168
+ export interface ScoutQuery {
169
+ url?: string;
170
+ title?: string;
171
+ feature?: string;
172
+ excludeUrls: string[];
173
+ }