explorbot 0.4.6 → 0.4.8
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/ai/chief.ts +3 -1
- package/boat/api-tester/src/ai/curler.ts +74 -66
- package/boat/api-tester/src/apibot.ts +1 -0
- package/boat/api-tester/src/cli.ts +2 -0
- package/boat/api-tester/src/config.ts +18 -1
- package/dist/boat/api-tester/src/ai/chief.js +3 -1
- package/dist/boat/api-tester/src/ai/curler.js +59 -56
- package/dist/boat/api-tester/src/apibot.js +1 -0
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +2 -2
- package/dist/rules/chief/general.md +2 -0
- package/dist/rules/researcher/pagination.md +7 -0
- package/dist/src/action-result.d.ts +6 -0
- package/dist/src/action-result.js +12 -0
- package/dist/src/action.js +3 -2
- package/dist/src/ai/navigator.js +1 -4
- package/dist/src/ai/pilot.js +8 -12
- package/dist/src/ai/planner/session-dedup.d.ts +2 -1
- package/dist/src/ai/planner/session-dedup.js +18 -1
- package/dist/src/ai/planner.js +12 -4
- package/dist/src/ai/provider.js +18 -4
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/pagination.d.ts +16 -0
- package/dist/src/ai/researcher/pagination.js +62 -0
- package/dist/src/ai/researcher/parser.d.ts +3 -0
- package/dist/src/ai/researcher/parser.js +22 -6
- package/dist/src/ai/researcher/sections.js +1 -1
- package/dist/src/ai/researcher.js +7 -2
- package/dist/src/ai/rules.js +17 -0
- package/dist/src/ai/scout.js +8 -2
- package/dist/src/ai/tester.js +1 -1
- package/dist/src/ai/tools.js +12 -4
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +14 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +14 -11
- package/dist/src/remote.d.ts +2 -0
- package/dist/src/remote.js +23 -16
- package/dist/src/utils/aria.d.ts +2 -0
- package/dist/src/utils/aria.js +6 -1
- package/dist/src/utils/code-extractor.js +6 -2
- package/dist/src/utils/markdown-query.d.ts +2 -0
- package/dist/src/utils/markdown-query.js +39 -0
- package/dist/src/utils/pagination.d.ts +16 -0
- package/dist/src/utils/pagination.js +20 -0
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
- package/package.json +2 -2
- package/rules/chief/general.md +2 -0
- package/rules/researcher/pagination.md +7 -0
- package/src/action-result.ts +16 -0
- package/src/action.ts +3 -2
- package/src/ai/navigator.ts +1 -4
- package/src/ai/pilot.ts +8 -12
- package/src/ai/planner/session-dedup.ts +16 -2
- package/src/ai/planner.ts +12 -4
- package/src/ai/provider.ts +18 -3
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher/pagination.ts +68 -0
- package/src/ai/researcher/parser.ts +23 -5
- package/src/ai/researcher/sections.ts +1 -1
- package/src/ai/researcher.ts +9 -3
- package/src/ai/rules.ts +17 -0
- package/src/ai/scout.ts +9 -2
- package/src/ai/tester.ts +1 -1
- package/src/ai/tools.ts +9 -4
- package/src/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/code-extractor.ts +6 -2
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Plan, type Test } from '../../test-plan.js';
|
|
2
2
|
import type { Constructor } from '../researcher/mixin.js';
|
|
3
3
|
export declare function WithSessionDedup<T extends Constructor>(Base: T): {
|
|
4
4
|
new (...args: any[]): {
|
|
@@ -9,4 +9,5 @@ export declare function WithSessionDedup<T extends Constructor>(Base: T): {
|
|
|
9
9
|
getPreviousSessionScenariosExcluding(plan: Plan): Set<string>;
|
|
10
10
|
};
|
|
11
11
|
} & T;
|
|
12
|
+
export declare function formatSessionTest(plan: Plan, test: Test): string;
|
|
12
13
|
export declare function clearSessionDedup(): void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TestResult } from "../../test-plan.js";
|
|
1
2
|
const previousPlans = [];
|
|
2
3
|
export function WithSessionDedup(Base) {
|
|
3
4
|
return class extends Base {
|
|
@@ -12,7 +13,7 @@ export function WithSessionDedup(Base) {
|
|
|
12
13
|
if (plan === this.currentPlan)
|
|
13
14
|
continue;
|
|
14
15
|
for (const test of plan.tests) {
|
|
15
|
-
lines.push(
|
|
16
|
+
lines.push(formatSessionTest(plan, test));
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
return lines.join('\n');
|
|
@@ -25,6 +26,22 @@ export function WithSessionDedup(Base) {
|
|
|
25
26
|
}
|
|
26
27
|
};
|
|
27
28
|
}
|
|
29
|
+
export function formatSessionTest(plan, test) {
|
|
30
|
+
const lastNote = Object.values(test.notes)
|
|
31
|
+
.filter((note) => note.message)
|
|
32
|
+
.pop();
|
|
33
|
+
let outcome = test.result;
|
|
34
|
+
if (!outcome)
|
|
35
|
+
outcome = 'pending';
|
|
36
|
+
if (!test.result && lastNote)
|
|
37
|
+
outcome = 'unfinished';
|
|
38
|
+
const line = `${plan.url || '/'} | ${test.style || 'default'} | ${outcome} | ${test.scenario}`;
|
|
39
|
+
if (!lastNote)
|
|
40
|
+
return line;
|
|
41
|
+
if (outcome !== TestResult.FAILED && outcome !== 'unfinished')
|
|
42
|
+
return line;
|
|
43
|
+
return `${line} — ${lastNote.message.slice(0, 140)}`;
|
|
44
|
+
}
|
|
28
45
|
export function clearSessionDedup() {
|
|
29
46
|
previousPlans.length = 0;
|
|
30
47
|
}
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -316,8 +316,10 @@ export class Planner extends PlannerBase {
|
|
|
316
316
|
You can suggest scenarios that can be tested only through web interface.
|
|
317
317
|
You can't test emails, database, SMS, or any external services.
|
|
318
318
|
Suggest scenarios that can be potentially verified by UI.
|
|
319
|
-
|
|
320
|
-
|
|
319
|
+
Prefer outcomes grounded in observed interface behavior.
|
|
320
|
+
Every expected outcome must be verifiable through the web interface.
|
|
321
|
+
If a page or subpage has not been observed, describe the expected visible result generically instead of inventing interface details.
|
|
322
|
+
Persistency after a reload counts only when the persisted state can be confirmed through the interface.
|
|
321
323
|
If there are subpages (pages with same URL path) plan testing of those subpages as well
|
|
322
324
|
Plan CRUD operations in order: create, read, update, delete.
|
|
323
325
|
Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
|
|
@@ -328,7 +330,7 @@ export class Planner extends PlannerBase {
|
|
|
328
330
|
If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
|
|
329
331
|
If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
|
|
330
332
|
Do not assume hidden data exists just because a control is present.
|
|
331
|
-
|
|
333
|
+
Do not put record IDs or unique record names in test plans. Describe which record is needed and let Pilot choose it during execution; name a specific record only when research shows a small, complete list of available records.
|
|
332
334
|
If the list is empty or no concrete item names are visible, do not invent "known" or "existing" items. Prefer empty-state, no-match search, clear-search, or read-only list behavior scenarios.
|
|
333
335
|
Search, filter, sorting, tab, and list scenarios must start from a stable page where those controls are visible; avoid transient create/edit/new URLs unless the scenario tests that form.
|
|
334
336
|
For option values and list items, use only visible or previously observed data; do not add create/update/delete setup unless the user explicitly requests that workflow.
|
|
@@ -404,12 +406,16 @@ export class Planner extends PlannerBase {
|
|
|
404
406
|
`);
|
|
405
407
|
}
|
|
406
408
|
}
|
|
409
|
+
let activeRegion = '';
|
|
410
|
+
if (state.overlay.isOpen)
|
|
411
|
+
activeRegion = `Active region: ${state.overlay.describe()} — the user's current focus area. Plan tests for the controls inside it first.`;
|
|
407
412
|
conversation.addUserText(dedent `
|
|
408
413
|
${this.buildApproach(style)}
|
|
409
414
|
|
|
410
415
|
<context>
|
|
411
416
|
URL: ${state.url || 'Unknown'}
|
|
412
417
|
Title: ${state.title || 'Unknown'}
|
|
418
|
+
${activeRegion}
|
|
413
419
|
</context>
|
|
414
420
|
`);
|
|
415
421
|
if (this.fisherman) {
|
|
@@ -536,7 +542,9 @@ export class Planner extends PlannerBase {
|
|
|
536
542
|
const sessionTests = this.getSessionTestsSummary();
|
|
537
543
|
if (sessionTests) {
|
|
538
544
|
conversation.addUserText(dedent `
|
|
539
|
-
Tests already planned in this session across all pages. DO NOT duplicate any of these
|
|
545
|
+
Tests already planned in this session across all pages, with how each one ended. DO NOT duplicate any of these.
|
|
546
|
+
A failed test means the app or the harness could not do what it tried: do not re-propose the same behavior on another page unless you can name what makes it work this time.
|
|
547
|
+
A failed or unfinished test carries the last thing it observed after the dash — read it before deciding that the behavior is worth trying again.
|
|
540
548
|
|
|
541
549
|
<session_tests>
|
|
542
550
|
${sessionTests}
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -32,6 +32,11 @@ function createHarmonyChannelFallbackTool() {
|
|
|
32
32
|
execute: async () => ({ message: 'Noted. Continue with your next action.' }),
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
|
+
function withHarmonyChannelFallback(tools) {
|
|
36
|
+
if (tools?.commentary)
|
|
37
|
+
return tools;
|
|
38
|
+
return { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
39
|
+
}
|
|
35
40
|
let telemetryRegistered = false;
|
|
36
41
|
let beforeExitFlushHooked = false;
|
|
37
42
|
let activeOtelSdk = null;
|
|
@@ -367,8 +372,8 @@ export class Provider {
|
|
|
367
372
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
368
373
|
promptLog(`Using model: ${modelName}`);
|
|
369
374
|
let toolsWithCommentary = tools;
|
|
370
|
-
if (
|
|
371
|
-
toolsWithCommentary =
|
|
375
|
+
if (options.toolChoice !== 'required')
|
|
376
|
+
toolsWithCommentary = withHarmonyChannelFallback(tools);
|
|
372
377
|
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
373
378
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
374
379
|
promptLog('Available tools:', toolNames);
|
|
@@ -378,9 +383,10 @@ export class Provider {
|
|
|
378
383
|
const stopConditions = [isStepCount(maxRoundtrips)];
|
|
379
384
|
if (extraStop)
|
|
380
385
|
stopConditions.push(extraStop);
|
|
381
|
-
|
|
386
|
+
let config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
382
387
|
let attemptMessages = messages;
|
|
383
388
|
let invalidRequestFeedbackAdded = false;
|
|
389
|
+
let requiredToolChoiceRelaxed = false;
|
|
384
390
|
const executedStepMessages = [];
|
|
385
391
|
try {
|
|
386
392
|
let response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
@@ -399,6 +405,11 @@ export class Provider {
|
|
|
399
405
|
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
400
406
|
attemptMessages = amended;
|
|
401
407
|
}
|
|
408
|
+
if (!requiredToolChoiceRelaxed && isRequiredToolChoiceError(error)) {
|
|
409
|
+
requiredToolChoiceRelaxed = true;
|
|
410
|
+
config = { ...config, tools: withHarmonyChannelFallback(tools), toolChoice: 'auto' };
|
|
411
|
+
tag('warning').log('Provider rejected required tool choice — retrying with automatic tool choice and channel fallback');
|
|
412
|
+
}
|
|
402
413
|
throw error;
|
|
403
414
|
}));
|
|
404
415
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
@@ -422,7 +433,7 @@ export class Provider {
|
|
|
422
433
|
}
|
|
423
434
|
catch (error) {
|
|
424
435
|
clearActivity();
|
|
425
|
-
if (error
|
|
436
|
+
if (isRequiredToolChoiceError(error)) {
|
|
426
437
|
return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
|
|
427
438
|
}
|
|
428
439
|
if (error?.name === 'AbortError')
|
|
@@ -659,6 +670,9 @@ function withInvalidRequestFeedback(messages, error) {
|
|
|
659
670
|
},
|
|
660
671
|
];
|
|
661
672
|
}
|
|
673
|
+
function isRequiredToolChoiceError(error) {
|
|
674
|
+
return error instanceof Error && error.message.includes('Tool choice is required');
|
|
675
|
+
}
|
|
662
676
|
function repairChannelMarker({ toolCall, tools }) {
|
|
663
677
|
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
664
678
|
if (markerIndex <= 0)
|
|
@@ -281,7 +281,7 @@ export function WithLocators(Base) {
|
|
|
281
281
|
if (sectionQuery.count() === 0)
|
|
282
282
|
sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
|
|
283
283
|
if (newCss) {
|
|
284
|
-
result.text = sectionQuery.query('blockquote[0]').
|
|
284
|
+
result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
|
|
285
285
|
}
|
|
286
286
|
else {
|
|
287
287
|
result.text = sectionQuery.query('blockquote[0]').replace('');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type Explorer from '../../explorer.js';
|
|
2
|
+
import { type ListMeasure, type PaginationStrategy } from '../../utils/pagination.js';
|
|
3
|
+
import { type Constructor } from './mixin.js';
|
|
4
|
+
import type { ResearchResult } from './research-result.js';
|
|
5
|
+
export declare function WithPagination<T extends Constructor>(Base: T): {
|
|
6
|
+
new (...args: any[]): {
|
|
7
|
+
explorer: Explorer;
|
|
8
|
+
detectPagination(result: ResearchResult): Promise<void>;
|
|
9
|
+
probeSection(css: string): Promise<PaginationStrategy | null>;
|
|
10
|
+
measure(css: string): Promise<ListMeasure | null>;
|
|
11
|
+
recordPagination(result: ResearchResult, name: string, strategy: PaginationStrategy): void;
|
|
12
|
+
};
|
|
13
|
+
} & T;
|
|
14
|
+
export interface PaginationMethods {
|
|
15
|
+
detectPagination(result: ResearchResult): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mdq } from "../../utils/markdown-query.js";
|
|
2
|
+
import { inspectList, restoreScroll } from "../../utils/pagination.js";
|
|
3
|
+
import { debugLog } from "./mixin.js";
|
|
4
|
+
import { extractPaginationFromBlockquote, parseDataSections, parseResearchSections } from "./parser.js";
|
|
5
|
+
export function WithPagination(Base) {
|
|
6
|
+
return class extends Base {
|
|
7
|
+
async detectPagination(result) {
|
|
8
|
+
const sections = [...parseResearchSections(result.text), ...parseDataSections(result.text)];
|
|
9
|
+
for (const section of sections) {
|
|
10
|
+
const css = section.containerCss;
|
|
11
|
+
if (!css)
|
|
12
|
+
continue;
|
|
13
|
+
if (extractPaginationFromBlockquote(section.rawMarkdown))
|
|
14
|
+
continue;
|
|
15
|
+
const strategy = await this.probeSection(css);
|
|
16
|
+
if (!strategy)
|
|
17
|
+
continue;
|
|
18
|
+
this.recordPagination(result, section.name, strategy);
|
|
19
|
+
debugLog(`Pagination in "${section.name}": ${strategy}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async probeSection(css) {
|
|
23
|
+
const before = await this.measure(css);
|
|
24
|
+
if (!before)
|
|
25
|
+
return null;
|
|
26
|
+
if (before.hasPagingControls)
|
|
27
|
+
return 'controls';
|
|
28
|
+
if (before.isFeed)
|
|
29
|
+
return 'infinite';
|
|
30
|
+
if (!before.scrolls)
|
|
31
|
+
return null;
|
|
32
|
+
const action = this.explorer.action();
|
|
33
|
+
const scrolled = await action.attempt(`I.scrollTo('${css} > *:last-child')`).catch(() => false);
|
|
34
|
+
if (!scrolled)
|
|
35
|
+
return null;
|
|
36
|
+
const after = await this.measure(css);
|
|
37
|
+
await this.explorer.withPage((page) => page.evaluate(restoreScroll, { css, scrollTop: before.scrollTop, pageScrollY: before.pageScrollY })).catch(() => { });
|
|
38
|
+
if (!after)
|
|
39
|
+
return null;
|
|
40
|
+
if (after.items > before.items)
|
|
41
|
+
return 'infinite';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
measure(css) {
|
|
45
|
+
return this.explorer
|
|
46
|
+
.withPage((page) => page.evaluate(inspectList, css))
|
|
47
|
+
.catch((err) => {
|
|
48
|
+
debugLog(`List measurement failed for '${css}': ${err.message}`);
|
|
49
|
+
return null;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
recordPagination(result, name, strategy) {
|
|
53
|
+
const escaped = name.replace(/"/g, '\\"');
|
|
54
|
+
let sectionQuery = mdq(result.text).query(`section2(~"${escaped}")`);
|
|
55
|
+
if (sectionQuery.count() === 0)
|
|
56
|
+
sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
|
|
57
|
+
if (sectionQuery.count() === 0)
|
|
58
|
+
return;
|
|
59
|
+
result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PaginationStrategy } from '../../utils/pagination.js';
|
|
1
2
|
export interface ResearchElement {
|
|
2
3
|
name: string;
|
|
3
4
|
type: string | null;
|
|
@@ -23,6 +24,8 @@ export declare const RESEARCH_COLUMN_ORDER: string[];
|
|
|
23
24
|
export declare function mapRowToElement(row: Record<string, string>): ResearchElement | null;
|
|
24
25
|
export declare function extractContainerFromBlockquote(sectionMarkdown: string): string | null;
|
|
25
26
|
export declare function parseResearchSections(markdown: string): ResearchSection[];
|
|
27
|
+
export declare function parseDataSections(markdown: string): ResearchSection[];
|
|
28
|
+
export declare function extractPaginationFromBlockquote(sectionMarkdown: string): PaginationStrategy | null;
|
|
26
29
|
export declare function extractValidContainers(researchText: string, opts?: {
|
|
27
30
|
exclude?: string[];
|
|
28
31
|
}): Array<{
|
|
@@ -57,13 +57,10 @@ export function mapRowToElement(row) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
export function extractContainerFromBlockquote(sectionMarkdown) {
|
|
60
|
-
const
|
|
61
|
-
if (!
|
|
60
|
+
const entry = mdq(sectionMarkdown).query('blockquote[0]').keyValue().container;
|
|
61
|
+
if (!entry)
|
|
62
62
|
return null;
|
|
63
|
-
const
|
|
64
|
-
if (!match)
|
|
65
|
-
return null;
|
|
66
|
-
const css = normalizeLocatorValue(match[1]);
|
|
63
|
+
const css = normalizeLocatorValue(entry);
|
|
67
64
|
if (!css || !/^[.#\[\w]/.test(css))
|
|
68
65
|
return null;
|
|
69
66
|
return css;
|
|
@@ -80,6 +77,25 @@ export function parseResearchSections(markdown) {
|
|
|
80
77
|
return { name: section.name, containerCss, elements, rawMarkdown: section.rawMarkdown, isExtended };
|
|
81
78
|
});
|
|
82
79
|
}
|
|
80
|
+
export function parseDataSections(markdown) {
|
|
81
|
+
return parseSections(markdown)
|
|
82
|
+
.filter((s) => s.name.toLowerCase().startsWith('data:'))
|
|
83
|
+
.map((section) => ({
|
|
84
|
+
name: section.name,
|
|
85
|
+
containerCss: extractContainerFromBlockquote(section.rawMarkdown),
|
|
86
|
+
elements: [],
|
|
87
|
+
rawMarkdown: section.rawMarkdown,
|
|
88
|
+
isExtended: false,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
export function extractPaginationFromBlockquote(sectionMarkdown) {
|
|
92
|
+
const value = mdq(sectionMarkdown).query('blockquote[0]').keyValue().pagination?.toLowerCase();
|
|
93
|
+
if (value === 'controls')
|
|
94
|
+
return 'controls';
|
|
95
|
+
if (value === 'infinite')
|
|
96
|
+
return 'infinite';
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
83
99
|
export function extractValidContainers(researchText, opts) {
|
|
84
100
|
const exclude = opts?.exclude || [];
|
|
85
101
|
return parseResearchSections(researchText)
|
|
@@ -63,7 +63,7 @@ export function WithSections(Base) {
|
|
|
63
63
|
}
|
|
64
64
|
async _researchSingleSection(name, description, ariaSnapshot, focusCss) {
|
|
65
65
|
const currentUrl = this.stateManager.getCurrentState()?.url || '';
|
|
66
|
-
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
|
|
66
|
+
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
|
|
67
67
|
const url = this.actionResult?.url || 'Unknown';
|
|
68
68
|
const title = this.actionResult?.title || 'Unknown';
|
|
69
69
|
let focusHint = '';
|
|
@@ -18,6 +18,7 @@ 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";
|
|
20
20
|
import { WithLocators } from "./researcher/locators.js";
|
|
21
|
+
import { WithPagination } from "./researcher/pagination.js";
|
|
21
22
|
import { extractValidContainers, formatResearchSummary, parseResearchSections } from "./researcher/parser.js";
|
|
22
23
|
import { ResearchResult } from "./researcher/research-result.js";
|
|
23
24
|
import { WithSections } from "./researcher/sections.js";
|
|
@@ -33,7 +34,7 @@ export const POSSIBLE_SECTIONS = {
|
|
|
33
34
|
menu: 'page menu (toolbar, context actions, filters, dropdowns)',
|
|
34
35
|
navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
|
|
35
36
|
};
|
|
36
|
-
const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent))));
|
|
37
|
+
const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent)))));
|
|
37
38
|
export class Researcher extends ResearcherBase {
|
|
38
39
|
ACTION_TOOLS = ['click'];
|
|
39
40
|
emoji = '🔍';
|
|
@@ -230,6 +231,9 @@ export class Researcher extends ResearcherBase {
|
|
|
230
231
|
await this.backfillCoordinates(result);
|
|
231
232
|
await this.backfillBrokenLocators(result);
|
|
232
233
|
}
|
|
234
|
+
if (!interrupted()) {
|
|
235
|
+
await this.detectPagination(result);
|
|
236
|
+
}
|
|
233
237
|
// Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
|
|
234
238
|
if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
|
|
235
239
|
const sections = parseResearchSections(result.text);
|
|
@@ -366,7 +370,7 @@ export class Researcher extends ResearcherBase {
|
|
|
366
370
|
|
|
367
371
|
${generalLocatorRuleText}
|
|
368
372
|
|
|
369
|
-
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
|
|
373
|
+
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
|
|
370
374
|
|
|
371
375
|
<section_identification>
|
|
372
376
|
Identify page sections in this priority order:
|
|
@@ -439,6 +443,7 @@ export class Researcher extends ResearcherBase {
|
|
|
439
443
|
- When a section contains a list of similar data items (records, entities, rows — content that varies by data, not by app UI), output it as a Data section with NO table.
|
|
440
444
|
- Data section heading MUST be a level-2 heading (##) that starts exactly with "Data:" — for example: "## Data: Suites List". Do NOT use ### or add section numbers.
|
|
441
445
|
- Data sections must NOT include a UI map table. Only include the container and a brief summary line.
|
|
446
|
+
- When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
|
|
442
447
|
- Example data section:
|
|
443
448
|
|
|
444
449
|
## Data: Suites List
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -177,6 +177,7 @@ export const capabilityGroundingRule = dedent `
|
|
|
177
177
|
When a scenario depends on a named action, menu item, status, option, workflow, or feature,
|
|
178
178
|
that capability must be visible or explicitly confirmed in the current research/page context
|
|
179
179
|
for the same target entity type.
|
|
180
|
+
Ground on the scenario's outcome, not a planned step's control label — a missing label is not a missing capability.
|
|
180
181
|
|
|
181
182
|
Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
|
|
182
183
|
Do not replace a requested action with a synonym or related action unless the UI explicitly
|
|
@@ -332,6 +333,22 @@ export const actionRule = dedent `
|
|
|
332
333
|
For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
|
|
333
334
|
|
|
334
335
|
|
|
336
|
+
### I.scrollTo
|
|
337
|
+
|
|
338
|
+
scrolls until the element is in view
|
|
339
|
+
|
|
340
|
+
I.scrollTo(<locator>)
|
|
341
|
+
|
|
342
|
+
Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
|
|
343
|
+
that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
|
|
344
|
+
|
|
345
|
+
<example>
|
|
346
|
+
I.scrollTo('.rows > *:last-child');
|
|
347
|
+
I.scrollTo({ role: 'listitem', text: 'Last entry' });
|
|
348
|
+
I.scrollPageToBottom();
|
|
349
|
+
</example>
|
|
350
|
+
|
|
351
|
+
|
|
335
352
|
### I.fillField
|
|
336
353
|
|
|
337
354
|
fills the field with the given value
|
package/dist/src/ai/scout.js
CHANGED
|
@@ -47,12 +47,18 @@ export class Scout {
|
|
|
47
47
|
maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
|
|
48
48
|
agentName: 'scout',
|
|
49
49
|
});
|
|
50
|
+
const responseText = invokeResult?.response?.text;
|
|
51
|
+
if (responseText?.trim()) {
|
|
52
|
+
finishFromText(responseText);
|
|
53
|
+
stop();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
50
56
|
if (!invokeResult?.toolExecutions?.length) {
|
|
51
|
-
finishFromText(invokeResult?.response?.text);
|
|
52
57
|
stop();
|
|
53
58
|
return;
|
|
54
59
|
}
|
|
55
|
-
if (iteration >= MAX_ITERATIONS) {
|
|
60
|
+
if (iteration >= MAX_ITERATIONS - 1) {
|
|
61
|
+
conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
|
|
56
62
|
const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
|
|
57
63
|
finishFromText(final?.response?.text);
|
|
58
64
|
stop();
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -29,7 +29,7 @@ const SAMPLE_FILES = {
|
|
|
29
29
|
};
|
|
30
30
|
export class Tester extends TaskAgent {
|
|
31
31
|
ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
32
|
-
DELEGATED_ACTION_TOOLS = ['interact'];
|
|
32
|
+
DELEGATED_ACTION_TOOLS = ['interact', 'visualClick'];
|
|
33
33
|
SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
|
|
34
34
|
emoji = '🧪';
|
|
35
35
|
requestStore;
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -311,6 +311,8 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
311
311
|
- Working with iframes (switch context with I.switchTo)
|
|
312
312
|
- Performing multiple form actions in a single batch
|
|
313
313
|
- Complex interactions requiring sequential commands
|
|
314
|
+
- Reaching items further down a list (I.scrollTo)
|
|
315
|
+
- Reloading the page to prove a change outlived it (I.reloadPage)
|
|
314
316
|
|
|
315
317
|
Example - filling a form with context (PREFERRED):
|
|
316
318
|
I.fillField('Username', 'John', '.login-form')
|
|
@@ -323,7 +325,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
323
325
|
I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
|
|
324
326
|
|
|
325
327
|
Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
|
|
326
|
-
Do not use: wait functions, amOnPage,
|
|
328
|
+
Do not use: wait functions, amOnPage, saveScreenshot
|
|
327
329
|
`,
|
|
328
330
|
inputSchema: z.object({
|
|
329
331
|
codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
|
|
@@ -365,10 +367,10 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
365
367
|
}
|
|
366
368
|
if (!hasObservablePageChange(toolResult)) {
|
|
367
369
|
activeNote.commit(TestResult.FAILED);
|
|
368
|
-
return failedToolResult('form', '
|
|
370
|
+
return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
|
|
369
371
|
...toolResult,
|
|
370
372
|
code: codeBlock,
|
|
371
|
-
suggestion: '
|
|
373
|
+
suggestion: 'The command ran without reaching anything. Re-locate the target, check whether another UI layer is active, then retry. If the goal was to load more of a list, no change means the collection has ended.',
|
|
372
374
|
});
|
|
373
375
|
}
|
|
374
376
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -1080,7 +1082,13 @@ export function successToolResult(action, data, source) {
|
|
|
1080
1082
|
return result;
|
|
1081
1083
|
}
|
|
1082
1084
|
export function isMajorPageChange(pageDiff) {
|
|
1083
|
-
|
|
1085
|
+
if (pageDiff.urlChanged === true)
|
|
1086
|
+
return false;
|
|
1087
|
+
if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD)
|
|
1088
|
+
return false;
|
|
1089
|
+
if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0)
|
|
1090
|
+
return false;
|
|
1091
|
+
return true;
|
|
1084
1092
|
}
|
|
1085
1093
|
export function hasFailedRequest(pageDiff) {
|
|
1086
1094
|
return (pageDiff.requests ?? []).some((request) => request.status >= 400);
|
|
@@ -3,5 +3,12 @@ import { BaseOption } from './base-option.js';
|
|
|
3
3
|
export declare class WsOption extends BaseOption {
|
|
4
4
|
flags: string;
|
|
5
5
|
description: string;
|
|
6
|
+
/**
|
|
7
|
+
* An open socket holds the event loop, and `WebSocket` has no `unref` on
|
|
8
|
+
* either runtime — so a command that ends by returning rather than through
|
|
9
|
+
* `showStatsAndExit` would never exit once it is attached. The option that
|
|
10
|
+
* opened the connection is what closes it.
|
|
11
|
+
*/
|
|
12
|
+
register(command: Command): void;
|
|
6
13
|
apply(options: Record<string, any>, command: Command): void;
|
|
7
14
|
}
|
|
@@ -3,6 +3,20 @@ import { BaseOption } from './base-option.js';
|
|
|
3
3
|
export class WsOption extends BaseOption {
|
|
4
4
|
flags = '--ws <url>';
|
|
5
5
|
description = 'Stream this run to a remote UI over WebSocket';
|
|
6
|
+
/**
|
|
7
|
+
* An open socket holds the event loop, and `WebSocket` has no `unref` on
|
|
8
|
+
* either runtime — so a command that ends by returning rather than through
|
|
9
|
+
* `showStatsAndExit` would never exit once it is attached. The option that
|
|
10
|
+
* opened the connection is what closes it.
|
|
11
|
+
*/
|
|
12
|
+
register(command) {
|
|
13
|
+
super.register(command);
|
|
14
|
+
command.hook('postAction', async () => {
|
|
15
|
+
if (!remote.isAttached())
|
|
16
|
+
return;
|
|
17
|
+
await remote.close(0);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
6
20
|
apply(options, command) {
|
|
7
21
|
const url = options.ws || process.env.EXPLORBOT_WS_URL;
|
|
8
22
|
if (!url)
|
package/dist/src/config.d.ts
CHANGED
|
@@ -287,6 +287,7 @@ export declare function modelName(model: unknown): string;
|
|
|
287
287
|
export declare function modelProvider(model: unknown): string;
|
|
288
288
|
export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
|
|
289
289
|
export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
|
|
290
|
+
export declare function resolveLangfuse(ai?: AIConfig): void;
|
|
290
291
|
export declare function resolveOutputRoot(baseUrl?: string): string;
|
|
291
292
|
export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
|
|
292
293
|
export declare function materializeKnowledge(outputRoot: string): void;
|
package/dist/src/config.js
CHANGED
|
@@ -377,17 +377,7 @@ export class ConfigParser {
|
|
|
377
377
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
378
378
|
config.playwright.url = options.baseUrl;
|
|
379
379
|
}
|
|
380
|
-
|
|
381
|
-
const langfuse = config.ai.langfuse;
|
|
382
|
-
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
383
|
-
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
384
|
-
config.ai.langfuse = {
|
|
385
|
-
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
386
|
-
publicKey,
|
|
387
|
-
secretKey,
|
|
388
|
-
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
389
|
-
};
|
|
390
|
-
}
|
|
380
|
+
resolveLangfuse(config.ai);
|
|
391
381
|
return config;
|
|
392
382
|
}
|
|
393
383
|
validateConfig(config) {
|
|
@@ -540,6 +530,19 @@ export async function resolveConfigModels(ai) {
|
|
|
540
530
|
agent.model = await resolveModel(agent.model);
|
|
541
531
|
}
|
|
542
532
|
}
|
|
533
|
+
export function resolveLangfuse(ai) {
|
|
534
|
+
if (!ai)
|
|
535
|
+
return;
|
|
536
|
+
const langfuse = ai.langfuse;
|
|
537
|
+
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
538
|
+
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
539
|
+
ai.langfuse = {
|
|
540
|
+
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
541
|
+
publicKey,
|
|
542
|
+
secretKey,
|
|
543
|
+
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
543
546
|
export function resolveOutputRoot(baseUrl) {
|
|
544
547
|
if (cachedOutputRoot)
|
|
545
548
|
return cachedOutputRoot;
|
package/dist/src/remote.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare class Remote implements LogDestination {
|
|
|
18
18
|
asks: Map<string, (value: string | null) => void>;
|
|
19
19
|
askCounter: number;
|
|
20
20
|
lastActivity: string | null;
|
|
21
|
+
closing: Promise<void> | null;
|
|
21
22
|
attach(url: string, command: string): void;
|
|
22
23
|
isAttached(): boolean;
|
|
23
24
|
send(type: string, data?: Record<string, unknown>): void;
|
|
@@ -30,6 +31,7 @@ export declare class Remote implements LogDestination {
|
|
|
30
31
|
* back out of flattened text.
|
|
31
32
|
*/
|
|
32
33
|
write(entry: TaggedLogEntry): void;
|
|
34
|
+
shutdown(exitCode: number): Promise<void>;
|
|
33
35
|
connect(): void;
|
|
34
36
|
reconnect(): void;
|
|
35
37
|
flush(): Promise<void>;
|
package/dist/src/remote.js
CHANGED
|
@@ -26,6 +26,7 @@ export class Remote {
|
|
|
26
26
|
asks = new Map();
|
|
27
27
|
askCounter = 0;
|
|
28
28
|
lastActivity = null;
|
|
29
|
+
closing = null;
|
|
29
30
|
attach(url, command) {
|
|
30
31
|
if (this.url)
|
|
31
32
|
return;
|
|
@@ -68,23 +69,12 @@ export class Remote {
|
|
|
68
69
|
});
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
close(exitCode) {
|
|
72
73
|
if (!this.url)
|
|
73
|
-
return;
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
this.
|
|
77
|
-
if (this.reconnectTimer)
|
|
78
|
-
clearTimeout(this.reconnectTimer);
|
|
79
|
-
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
80
|
-
// route them into a closed socket and park them until the ask times out.
|
|
81
|
-
executionController.clearInputCallback();
|
|
82
|
-
for (const resolve of this.asks.values())
|
|
83
|
-
resolve(null);
|
|
84
|
-
this.asks.clear();
|
|
85
|
-
this.queue = [];
|
|
86
|
-
this.socket?.close();
|
|
87
|
-
this.socket = null;
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
if (!this.closing)
|
|
76
|
+
this.closing = this.shutdown(exitCode);
|
|
77
|
+
return this.closing;
|
|
88
78
|
}
|
|
89
79
|
isEnabled() {
|
|
90
80
|
return this.isAttached();
|
|
@@ -112,6 +102,23 @@ export class Remote {
|
|
|
112
102
|
error: this.errorOf(entry.originalArgs),
|
|
113
103
|
});
|
|
114
104
|
}
|
|
105
|
+
async shutdown(exitCode) {
|
|
106
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
107
|
+
await this.flush();
|
|
108
|
+
this.url = null;
|
|
109
|
+
if (this.reconnectTimer)
|
|
110
|
+
clearTimeout(this.reconnectTimer);
|
|
111
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
112
|
+
// route them into a closed socket and park them until the ask times out.
|
|
113
|
+
executionController.clearInputCallback();
|
|
114
|
+
for (const resolve of this.asks.values())
|
|
115
|
+
resolve(null);
|
|
116
|
+
this.asks.clear();
|
|
117
|
+
this.queue = [];
|
|
118
|
+
this.socket?.close();
|
|
119
|
+
this.socket = null;
|
|
120
|
+
this.closing = null;
|
|
121
|
+
}
|
|
115
122
|
connect() {
|
|
116
123
|
if (!this.url)
|
|
117
124
|
return;
|