explorbot 0.5.1 → 0.5.3
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/bin/explorbot-cli.ts +3 -0
- package/dist/bin/explorbot-cli.js +2 -0
- package/dist/package.json +1 -1
- package/dist/src/action.d.ts +1 -0
- package/dist/src/action.js +9 -0
- package/dist/src/ai/historian/codeceptjs.js +2 -0
- package/dist/src/ai/judge-provider.js +5 -0
- package/dist/src/ai/judge-tool.js +5 -1
- package/dist/src/ai/judge.d.ts +3 -1
- package/dist/src/ai/judge.js +29 -6
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +20 -4
- package/dist/src/ai/provider.js +14 -7
- package/dist/src/ai/rerunner.js +3 -1
- package/dist/src/ai/rules.js +1 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +22 -4
- package/dist/src/ai/tools.js +34 -14
- package/dist/src/config.d.ts +11 -3
- package/dist/src/config.js +42 -6
- package/dist/src/explorbot.js +4 -0
- package/dist/src/explorer.d.ts +2 -0
- package/dist/src/explorer.js +33 -1
- package/dist/src/global-config.js +1 -1
- package/dist/src/utils/test-files.d.ts +5 -0
- package/dist/src/utils/test-files.js +28 -1
- package/docs/basics/providers.md +3 -2
- package/docs/reference/commands.md +12 -0
- package/docs/web-testing/decisions.md +18 -7
- package/package.json +1 -1
- package/src/action.ts +10 -0
- package/src/ai/historian/codeceptjs.ts +2 -0
- package/src/ai/judge-provider.ts +6 -0
- package/src/ai/judge-tool.ts +5 -1
- package/src/ai/judge.ts +25 -6
- package/src/ai/pilot.ts +20 -4
- package/src/ai/provider.ts +13 -7
- package/src/ai/rerunner.ts +3 -1
- package/src/ai/rules.ts +1 -1
- package/src/ai/tester.ts +23 -3
- package/src/ai/tools.ts +37 -21
- package/src/config.ts +49 -7
- package/src/explorbot.ts +2 -0
- package/src/explorer.ts +37 -2
- package/src/global-config.ts +1 -1
- package/src/utils/test-files.ts +27 -1
package/bin/explorbot-cli.ts
CHANGED
|
@@ -56,6 +56,7 @@ interface CLIOptions {
|
|
|
56
56
|
debug?: boolean;
|
|
57
57
|
config?: string;
|
|
58
58
|
path?: string;
|
|
59
|
+
baseUrl?: string;
|
|
59
60
|
show?: boolean;
|
|
60
61
|
headless?: boolean;
|
|
61
62
|
incognito?: boolean;
|
|
@@ -66,6 +67,7 @@ interface CLIOptions {
|
|
|
66
67
|
function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
|
|
67
68
|
return {
|
|
68
69
|
from,
|
|
70
|
+
baseUrl: options.baseUrl,
|
|
69
71
|
verbose: options.verbose || options.debug,
|
|
70
72
|
config: options.config,
|
|
71
73
|
path: options.path,
|
|
@@ -83,6 +85,7 @@ function addCommonOptions(cmd: Command): Command {
|
|
|
83
85
|
.option('--debug', 'Enable debug logging (same as --verbose)')
|
|
84
86
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
85
87
|
.option('-p, --path <path>', 'Working directory path')
|
|
88
|
+
.option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
|
|
86
89
|
.option('-s, --show', 'Show browser window')
|
|
87
90
|
.option('--headless', 'Run browser in headless mode')
|
|
88
91
|
.option('--incognito', 'Run without recording experiences')
|
|
@@ -46,6 +46,7 @@ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !prin
|
|
|
46
46
|
function buildExplorBotOptions(from, options) {
|
|
47
47
|
return {
|
|
48
48
|
from,
|
|
49
|
+
baseUrl: options.baseUrl,
|
|
49
50
|
verbose: options.verbose || options.debug,
|
|
50
51
|
config: options.config,
|
|
51
52
|
path: options.path,
|
|
@@ -62,6 +63,7 @@ function addCommonOptions(cmd) {
|
|
|
62
63
|
.option('--debug', 'Enable debug logging (same as --verbose)')
|
|
63
64
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
64
65
|
.option('-p, --path <path>', 'Working directory path')
|
|
66
|
+
.option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
|
|
65
67
|
.option('-s, --show', 'Show browser window')
|
|
66
68
|
.option('--headless', 'Run browser in headless mode')
|
|
67
69
|
.option('--incognito', 'Run without recording experiences')
|
package/dist/package.json
CHANGED
package/dist/src/action.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ declare class Action {
|
|
|
48
48
|
captureBrowserLogs(): Promise<any[]>;
|
|
49
49
|
executeOnce(code: string, verbatim?: boolean): Promise<Action>;
|
|
50
50
|
attempt(codeBlock: string, originalMessage?: string): Promise<boolean>;
|
|
51
|
+
attemptExactElementIndex(codeBlock: string, elementIndex: number, originalMessage?: string): Promise<boolean>;
|
|
51
52
|
exitIframe(): Promise<void>;
|
|
52
53
|
getActor(): CodeceptJS.I;
|
|
53
54
|
getActionResult(): ActionResult | null;
|
package/dist/src/action.js
CHANGED
|
@@ -105,6 +105,7 @@ class Action {
|
|
|
105
105
|
if (includeScreenshot) {
|
|
106
106
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
107
107
|
const screenshotPath = join(statesDir, filename);
|
|
108
|
+
await page?.screencast?.hideOverlays().catch(() => { });
|
|
108
109
|
screenshotFile = await page
|
|
109
110
|
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
110
111
|
.then(() => {
|
|
@@ -115,6 +116,7 @@ class Action {
|
|
|
115
116
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
116
117
|
return undefined;
|
|
117
118
|
});
|
|
119
|
+
await page?.screencast?.showOverlays().catch(() => { });
|
|
118
120
|
}
|
|
119
121
|
// Save HTML to file
|
|
120
122
|
const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
|
|
@@ -461,6 +463,13 @@ class Action {
|
|
|
461
463
|
return false;
|
|
462
464
|
}
|
|
463
465
|
}
|
|
466
|
+
async attemptExactElementIndex(codeBlock, elementIndex, originalMessage) {
|
|
467
|
+
const applyElementIndex = (step) => {
|
|
468
|
+
step.opts = { ...step.opts, elementIndex };
|
|
469
|
+
};
|
|
470
|
+
codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
|
|
471
|
+
return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
|
|
472
|
+
}
|
|
464
473
|
async exitIframe() {
|
|
465
474
|
if (!this.playwrightHelper.frame)
|
|
466
475
|
return;
|
|
@@ -6,6 +6,7 @@ import { tag } from "../../utils/logger.js";
|
|
|
6
6
|
import { relativeToCwd } from "../../utils/next-steps.js";
|
|
7
7
|
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
|
|
8
8
|
import { safeFilename } from "../../utils/strings.js";
|
|
9
|
+
import { ensureCodeceptResolvable } from "../../utils/test-files.js";
|
|
9
10
|
import { ASSERTION_TOOLS } from "../tools.js";
|
|
10
11
|
import { escapeString, getExecutionLabel } from "./utils.js";
|
|
11
12
|
export function WithCodeceptJS(Base) {
|
|
@@ -79,6 +80,7 @@ export function WithCodeceptJS(Base) {
|
|
|
79
80
|
}
|
|
80
81
|
const testsDir = ConfigParser.getInstance().getTestsDir();
|
|
81
82
|
mkdirSync(testsDir, { recursive: true });
|
|
83
|
+
ensureCodeceptResolvable(testsDir);
|
|
82
84
|
const filePath = join(testsDir, safeFilename(plan.title, '.js'));
|
|
83
85
|
writeFileSync(filePath, lines.join('\n'));
|
|
84
86
|
this.savedFiles.add(filePath);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Observability } from "../observability.js";
|
|
2
|
+
import { redactSecrets } from "../utils/secrets.js";
|
|
1
3
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
2
4
|
export class JudgeProvider {
|
|
3
5
|
model;
|
|
@@ -19,6 +21,9 @@ export class JudgeProvider {
|
|
|
19
21
|
if (options)
|
|
20
22
|
q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
|
|
21
23
|
const body = JSON.stringify({ model: this.model, state, questions: { q } });
|
|
24
|
+
const span = Observability.getSpan();
|
|
25
|
+
span?.setAttribute('langfuse.observation.input', redactSecrets(body));
|
|
26
|
+
span?.setAttribute('ai.telemetry.metadata.judgeEndpoint', this.url);
|
|
22
27
|
const controller = new AbortController();
|
|
23
28
|
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
24
29
|
try {
|
|
@@ -9,7 +9,11 @@ export function createJudgeTool(deps, buildState) {
|
|
|
9
9
|
return {
|
|
10
10
|
judge: tool({
|
|
11
11
|
description: dedent `
|
|
12
|
-
|
|
12
|
+
Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
|
|
13
|
+
No screenshot, no HTML, produces no test code.
|
|
14
|
+
Use for in-flow judgements you would otherwise guess: did the last action already take effect, which of several similar elements the scenario means, is this the expected page or dialog.
|
|
15
|
+
Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
|
|
16
|
+
Phrase the question literally and concretely.
|
|
13
17
|
`,
|
|
14
18
|
inputSchema: z.object({
|
|
15
19
|
question: z.string().describe('The statement to confirm, or the question the options answer'),
|
package/dist/src/ai/judge.d.ts
CHANGED
|
@@ -15,13 +15,15 @@ export declare class Judge {
|
|
|
15
15
|
tool: boolean;
|
|
16
16
|
direct: boolean;
|
|
17
17
|
};
|
|
18
|
+
threshold: number;
|
|
18
19
|
constructor(provider: JudgeProvider, enabled: {
|
|
19
20
|
tool: boolean;
|
|
20
21
|
direct: boolean;
|
|
21
|
-
});
|
|
22
|
+
}, threshold?: number);
|
|
22
23
|
static fromConfig(config: AIConfig['decisionModel']): Judge | null;
|
|
23
24
|
get toolEnabled(): boolean;
|
|
24
25
|
decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
|
|
26
|
+
pick(question: string, options: string[], state: unknown): Promise<number | null>;
|
|
25
27
|
consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
|
|
26
28
|
request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
|
|
27
29
|
recordFailure(error: unknown): null;
|
package/dist/src/ai/judge.js
CHANGED
|
@@ -5,6 +5,7 @@ import { JudgeProvider } from "./judge-provider.js";
|
|
|
5
5
|
const debugLog = createDebug('explorbot:judge');
|
|
6
6
|
const APPROVAL_THRESHOLD = 0.7;
|
|
7
7
|
export const UNDECIDED = 'undecided';
|
|
8
|
+
const UNDECIDED_HINT = `Choose "${UNDECIDED}" if you are not sure or no option fits.`;
|
|
8
9
|
export const JUDGE_PAGE_CAP = 12000;
|
|
9
10
|
export class Decision {
|
|
10
11
|
value;
|
|
@@ -23,14 +24,19 @@ export class Decision {
|
|
|
23
24
|
export class Judge {
|
|
24
25
|
provider;
|
|
25
26
|
enabled;
|
|
26
|
-
|
|
27
|
+
threshold;
|
|
28
|
+
constructor(provider, enabled, threshold = APPROVAL_THRESHOLD) {
|
|
27
29
|
this.provider = provider;
|
|
28
30
|
this.enabled = enabled;
|
|
31
|
+
this.threshold = threshold;
|
|
29
32
|
}
|
|
30
33
|
static fromConfig(config) {
|
|
31
34
|
if (!config)
|
|
32
35
|
return null;
|
|
33
|
-
|
|
36
|
+
const threshold = config.threshold ?? APPROVAL_THRESHOLD;
|
|
37
|
+
if (!(threshold > 0 && threshold < 1))
|
|
38
|
+
throw new Error(`decisionModel.threshold must be between 0 and 1, got ${config.threshold}`);
|
|
39
|
+
return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false }, threshold);
|
|
34
40
|
}
|
|
35
41
|
get toolEnabled() {
|
|
36
42
|
return this.enabled.tool;
|
|
@@ -40,13 +46,23 @@ export class Judge {
|
|
|
40
46
|
return new Decision(null, 0);
|
|
41
47
|
return this.consult(question, options, state);
|
|
42
48
|
}
|
|
49
|
+
async pick(question, options, state) {
|
|
50
|
+
if (new Set(options).size < options.length)
|
|
51
|
+
return null;
|
|
52
|
+
const decision = await this.decide(question, [...options, UNDECIDED], state);
|
|
53
|
+
if (!decision.value)
|
|
54
|
+
return null;
|
|
55
|
+
return options.indexOf(decision.value) + 1;
|
|
56
|
+
}
|
|
43
57
|
async consult(question, options, state) {
|
|
44
58
|
if (Array.isArray(options) && options.length < 2)
|
|
45
59
|
return new Decision(null, 0);
|
|
46
60
|
return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
|
|
47
61
|
setActivity('⚖️ Asking judge...', 'ai');
|
|
48
62
|
const decision = await this.request(question, options, state).finally(() => clearActivity());
|
|
49
|
-
Observability.getSpan()
|
|
63
|
+
const span = Observability.getSpan();
|
|
64
|
+
span?.setAttribute('ai.telemetry.metadata.judgeQuestion', question);
|
|
65
|
+
span?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence, threshold: this.threshold, model: this.provider.model }));
|
|
50
66
|
return decision;
|
|
51
67
|
});
|
|
52
68
|
}
|
|
@@ -54,10 +70,14 @@ export class Judge {
|
|
|
54
70
|
let list;
|
|
55
71
|
if (Array.isArray(options))
|
|
56
72
|
list = options;
|
|
57
|
-
|
|
73
|
+
let instructions = question;
|
|
74
|
+
if (list?.includes(UNDECIDED))
|
|
75
|
+
instructions = `${question} ${UNDECIDED_HINT}`;
|
|
76
|
+
const answer = await this.provider.decide(state, instructions, list).catch((error) => this.recordFailure(error));
|
|
58
77
|
if (!answer)
|
|
59
78
|
return new Decision(null, 0);
|
|
60
|
-
|
|
79
|
+
Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
|
|
80
|
+
if (answer.probability <= this.threshold)
|
|
61
81
|
return new Decision(null, answer.probability);
|
|
62
82
|
if (answer.value === UNDECIDED)
|
|
63
83
|
return new Decision(null, answer.probability);
|
|
@@ -65,7 +85,10 @@ export class Judge {
|
|
|
65
85
|
}
|
|
66
86
|
recordFailure(error) {
|
|
67
87
|
debugLog('judge declined: %s', error);
|
|
68
|
-
Observability.getSpan()
|
|
88
|
+
const span = Observability.getSpan();
|
|
89
|
+
span?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
|
|
90
|
+
span?.setAttribute('langfuse.observation.level', 'ERROR');
|
|
91
|
+
span?.setAttribute('langfuse.observation.status_message', String(error));
|
|
69
92
|
return null;
|
|
70
93
|
}
|
|
71
94
|
}
|
package/dist/src/ai/pilot.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export declare class Pilot implements Agent {
|
|
|
41
41
|
buildVerdictSystemPrompt(task: Test): string;
|
|
42
42
|
planTest(task: Test, currentState: ActionResult): Promise<string>;
|
|
43
43
|
reviewNewPage(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
44
|
+
periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
44
45
|
analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
45
46
|
settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
|
|
46
47
|
settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>>;
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -11,7 +11,7 @@ import { createDebug, tag } from "../utils/logger.js";
|
|
|
11
11
|
const debugLog = createDebug('explorbot:pilot');
|
|
12
12
|
import { truncateJson } from "../utils/strings.js";
|
|
13
13
|
import { createAskApiTool } from "./fisherman/tools.js";
|
|
14
|
-
import { UNDECIDED } from "./judge.js";
|
|
14
|
+
import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
|
|
15
15
|
import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
|
|
16
16
|
import { isInteractive } from "./task-agent.js";
|
|
17
17
|
import { withdrawVisionTools } from "./tools.js";
|
|
@@ -477,6 +477,22 @@ export class Pilot {
|
|
|
477
477
|
Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
|
|
478
478
|
`, 'pilot.reviewNewPage', { tools: true, maxToolRoundtrips: 2, task });
|
|
479
479
|
}
|
|
480
|
+
async periodicAnalyzeProgress(task, currentState, testerConversation) {
|
|
481
|
+
const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
|
|
482
|
+
const healthy = await this.judge?.decide('The recent actions advance the scenario toward its remaining expected outcomes.', null, {
|
|
483
|
+
scenario: task.scenario,
|
|
484
|
+
plannedSteps: task.plannedSteps,
|
|
485
|
+
expectations: this.formatExpectations(task),
|
|
486
|
+
runLog: task.notesToString() || 'No steps recorded.',
|
|
487
|
+
visitedUrls: task.getVisitedUrls({ localOnly: true }),
|
|
488
|
+
state: this.buildStateContext(currentState),
|
|
489
|
+
page: currentState.getCompactARIA().slice(0, JUDGE_PAGE_CAP),
|
|
490
|
+
recentActions: this.formatActions(toolCalls),
|
|
491
|
+
});
|
|
492
|
+
if (healthy?.approved)
|
|
493
|
+
return '';
|
|
494
|
+
return this.analyzeProgress(task, currentState, testerConversation);
|
|
495
|
+
}
|
|
480
496
|
async analyzeProgress(task, currentState, testerConversation) {
|
|
481
497
|
tag('substep').log('Pilot analyzing progress...');
|
|
482
498
|
if (!this.conversation) {
|
|
@@ -487,9 +503,6 @@ export class Pilot {
|
|
|
487
503
|
const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
|
|
488
504
|
const actionsContext = this.formatActions(toolCalls);
|
|
489
505
|
const stateContext = this.buildStateContext(currentState);
|
|
490
|
-
const healthy = await this.judge?.decide('The run is moving toward the goal and can continue without a supervisor reviewing it now.', null, { scenario: task.scenario, state: stateContext, recentActions: actionsContext });
|
|
491
|
-
if (healthy?.approved)
|
|
492
|
-
return '';
|
|
493
506
|
const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
|
|
494
507
|
const text = await this.sendToPilot(dedent `
|
|
495
508
|
START URL: ${task.startUrl}
|
|
@@ -1046,6 +1059,9 @@ export class Pilot {
|
|
|
1046
1059
|
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1047
1060
|
if (consoleError)
|
|
1048
1061
|
line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1062
|
+
const disabledReason = t.output?.disabledReason?.[0];
|
|
1063
|
+
if (disabledReason)
|
|
1064
|
+
line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1049
1065
|
return line;
|
|
1050
1066
|
})
|
|
1051
1067
|
.join('\n\n');
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
2
3
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
4
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
-
import dedent from 'dedent';
|
|
6
5
|
import { APICallError, NoObjectGeneratedError, asSchema, extractJsonMiddleware, generateObject, generateText, isStepCount, parsePartialJson, registerTelemetry, tool, wrapLanguageModel } from 'ai';
|
|
6
|
+
import dedent from 'dedent';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { clearActivity, setActivity } from "../activity.js";
|
|
9
9
|
import { configuredModels, modelName as getModelName } from '../config.js';
|
|
@@ -51,15 +51,16 @@ const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum
|
|
|
51
51
|
function extractCachedTokens(usage) {
|
|
52
52
|
return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
53
53
|
}
|
|
54
|
-
function abortAfterIdle(ms,
|
|
54
|
+
function abortAfterIdle(ms, state, controller, busy) {
|
|
55
55
|
return new Promise((_, reject) => {
|
|
56
56
|
const tick = () => {
|
|
57
|
-
if (
|
|
57
|
+
if (state.cancelled)
|
|
58
58
|
return;
|
|
59
59
|
if (executionController.isAwaitingInput() || busy.tools > 0) {
|
|
60
60
|
setTimeout(tick, ms);
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
|
+
state.timedOut = true;
|
|
63
64
|
controller.abort();
|
|
64
65
|
reject(new Error('AI request timeout'));
|
|
65
66
|
};
|
|
@@ -232,14 +233,20 @@ export class Provider {
|
|
|
232
233
|
});
|
|
233
234
|
}
|
|
234
235
|
async raceWithIdleTimeout(fn, timeoutMs, busy = { tools: 0 }) {
|
|
235
|
-
const
|
|
236
|
+
const state = { cancelled: false, timedOut: false };
|
|
236
237
|
const controller = new AbortController();
|
|
237
238
|
const combinedSignal = combinedAbortSignal(controller);
|
|
238
239
|
try {
|
|
239
|
-
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs,
|
|
240
|
+
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, state, controller, busy)]);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (state.timedOut && !executionController.getAbortSignal()?.aborted) {
|
|
244
|
+
throw new Error('AI request timeout', { cause: error });
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
240
247
|
}
|
|
241
248
|
finally {
|
|
242
|
-
|
|
249
|
+
state.cancelled = true;
|
|
243
250
|
}
|
|
244
251
|
}
|
|
245
252
|
async recoverFromContextLength(error, messages, options, retry) {
|
package/dist/src/ai/rerunner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { relative, resolve } from 'node:path';
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
3
3
|
import { tool } from 'ai';
|
|
4
4
|
import { createBashTool } from 'bash-tool';
|
|
5
5
|
import chalk from 'chalk';
|
|
@@ -19,6 +19,7 @@ import { createDebug, tag } from "../utils/logger.js";
|
|
|
19
19
|
import { loop } from "../utils/loop.js";
|
|
20
20
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
21
21
|
import { isInternalStep } from "../utils/step-analyzer.js";
|
|
22
|
+
import { ensureCodeceptResolvable } from "../utils/test-files.js";
|
|
22
23
|
import { toolExecutionLabel } from "./conversation.js";
|
|
23
24
|
import { actionRule, locatorRule, sectionContextRule } from "./rules.js";
|
|
24
25
|
import { TaskAgent } from "./task-agent.js";
|
|
@@ -122,6 +123,7 @@ export class Rerunner extends TaskAgent {
|
|
|
122
123
|
const mocha = codeceptjs.container.mocha();
|
|
123
124
|
mocha.reporter(class {
|
|
124
125
|
});
|
|
126
|
+
ensureCodeceptResolvable(dirname(absPath));
|
|
125
127
|
mocha.files = [absPath];
|
|
126
128
|
mocha.loadFiles();
|
|
127
129
|
let testIndex = 0;
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -224,7 +224,7 @@ export const unexpectedPopupRule = dedent `
|
|
|
224
224
|
If a modal/popup appeared that you didn't expect, dismiss it first before continuing with original task.
|
|
225
225
|
If elements become hidden or unclickable (timeout errors on visible elements), a dialog or overlay may have appeared on top.
|
|
226
226
|
If a click error mentions "intercepts pointer events", another element is covering the target — dismiss it first.
|
|
227
|
-
If buttons are disabled unexpectedly, check
|
|
227
|
+
If buttons are disabled unexpectedly, trust any explanation the app gives (tooltip, hint text) over your own guess; otherwise check for a blocking popup or empty required fields.
|
|
228
228
|
|
|
229
229
|
Dismiss strategy (try in order):
|
|
230
230
|
1. I.pressKey('Escape') — press Escape to dismiss
|
package/dist/src/ai/tester.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
51
51
|
success: boolean;
|
|
52
52
|
}>;
|
|
53
53
|
shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean;
|
|
54
|
+
get isStruggling(): boolean;
|
|
54
55
|
shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean;
|
|
55
56
|
prepareInstructionsForNextStep(task: Test): Promise<string>;
|
|
56
57
|
reinjectContextIfNeeded(iteration: number, currentState: ActionResult): Promise<string>;
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -285,7 +285,13 @@ export class Tester extends TaskAgent {
|
|
|
285
285
|
nextStep += `\n\n${guidance}`;
|
|
286
286
|
}
|
|
287
287
|
else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
|
|
288
|
-
|
|
288
|
+
let guidance;
|
|
289
|
+
if (this.isStruggling) {
|
|
290
|
+
guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
|
|
294
|
+
}
|
|
289
295
|
if (guidance)
|
|
290
296
|
nextStep += `\n\n${guidance}`;
|
|
291
297
|
this.consecutiveFailures = 0;
|
|
@@ -432,9 +438,7 @@ export class Tester extends TaskAgent {
|
|
|
432
438
|
this.regionTransitioned = false;
|
|
433
439
|
return true;
|
|
434
440
|
}
|
|
435
|
-
if (this.
|
|
436
|
-
return true;
|
|
437
|
-
if (this.consecutiveEmptyResults >= 2)
|
|
441
|
+
if (this.isStruggling)
|
|
438
442
|
return true;
|
|
439
443
|
if (iteration % this.progressCheckInterval !== 0)
|
|
440
444
|
return false;
|
|
@@ -442,6 +446,9 @@ export class Tester extends TaskAgent {
|
|
|
442
446
|
return false;
|
|
443
447
|
return true;
|
|
444
448
|
}
|
|
449
|
+
get isStruggling() {
|
|
450
|
+
return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
|
|
451
|
+
}
|
|
445
452
|
shouldStopForStalledExecution(task, previousState, toolExecutions) {
|
|
446
453
|
if (task.hasFinished)
|
|
447
454
|
return false;
|
|
@@ -675,6 +682,15 @@ export class Tester extends TaskAgent {
|
|
|
675
682
|
return meta;
|
|
676
683
|
}
|
|
677
684
|
getSystemMessage() {
|
|
685
|
+
let judgeRule = '';
|
|
686
|
+
if (this.agentTools?.judge) {
|
|
687
|
+
judgeRule = dedent `
|
|
688
|
+
<judge_rule>
|
|
689
|
+
For a quick judgement about the current page (did the last action already take effect, which element the scenario means), ask judge() before see().
|
|
690
|
+
Keep see() for what only a screenshot shows.
|
|
691
|
+
</judge_rule>
|
|
692
|
+
`;
|
|
693
|
+
}
|
|
678
694
|
return dedent `
|
|
679
695
|
<role>
|
|
680
696
|
You are a senior test automation engineer with expertise in CodeceptJS and exploratory testing.
|
|
@@ -774,6 +790,8 @@ export class Tester extends TaskAgent {
|
|
|
774
790
|
|
|
775
791
|
${capabilityGroundingRule}
|
|
776
792
|
|
|
793
|
+
${judgeRule}
|
|
794
|
+
|
|
777
795
|
${dataProtectionRules}
|
|
778
796
|
|
|
779
797
|
${this.provider.getSystemPromptForAgent('tester', this.stateManager.getCurrentState()?.url) || ''}
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -14,7 +14,7 @@ import { pause } from '../utils/loop.js';
|
|
|
14
14
|
import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
|
|
15
15
|
import { WebElement } from "../utils/web-element.js";
|
|
16
16
|
import { createJudgeTool } from "./judge-tool.js";
|
|
17
|
-
import { JUDGE_PAGE_CAP
|
|
17
|
+
import { JUDGE_PAGE_CAP } from "./judge.js";
|
|
18
18
|
import { sectionContextRule } from "./rules.js";
|
|
19
19
|
import { isInteractive } from "./task-agent.js";
|
|
20
20
|
const debugLog = createDebug('explorbot:tools');
|
|
@@ -92,13 +92,20 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
92
92
|
let ambiguityError = null;
|
|
93
93
|
for (let i = 0; i < commands.length; i++) {
|
|
94
94
|
const command = transformContainsCommand(commands[i]);
|
|
95
|
-
|
|
95
|
+
let success = await action.attempt(command, explanation);
|
|
96
96
|
const attempt = { command, success };
|
|
97
97
|
if (action.lastError)
|
|
98
98
|
attempt.error = errorText(action.lastError);
|
|
99
99
|
attempts.push(attempt);
|
|
100
|
-
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound')
|
|
100
|
+
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') {
|
|
101
101
|
ambiguityError = action.lastError;
|
|
102
|
+
if (judge) {
|
|
103
|
+
const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
|
|
104
|
+
const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
|
|
105
|
+
if (index)
|
|
106
|
+
success = await action.attemptExactElementIndex(command, index, explanation);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
102
109
|
if (success) {
|
|
103
110
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
|
|
104
111
|
if (!hasObservablePageChange(toolResult)) {
|
|
@@ -123,11 +130,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
123
130
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
|
|
124
131
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
125
132
|
const suggestion = clickFailureSuggestion(attempts);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
const data = { ...toolResult, attempts, suggestion };
|
|
134
|
+
const clickError = ambiguityError || action.lastError;
|
|
135
|
+
for (const disabled of attempts.filter((a) => a.error?.includes('not enabled'))) {
|
|
136
|
+
const hoverCommand = disabled.command.replace('I.click(', 'I.moveCursorTo(');
|
|
137
|
+
const beforeHover = ActionResult.fromState(stateManager.getCurrentState());
|
|
138
|
+
if (!(await action.attempt(hoverCommand, 'Hover the disabled element to reveal why it is disabled')))
|
|
139
|
+
continue;
|
|
140
|
+
const hoverResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(beforeHover, hoverCommand);
|
|
141
|
+
const shown = hoverResult.pageDiff?.messages;
|
|
142
|
+
if (!shown?.length)
|
|
143
|
+
break;
|
|
144
|
+
data.disabledReason = shown;
|
|
145
|
+
data.suggestion = `Element is DISABLED. Hovering it showed: "${shown.join(' | ')}". That is why — act on it or report it, do not guess another cause.`;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
return failedToolResult('click', 'All click commands failed', data, clickError);
|
|
131
149
|
},
|
|
132
150
|
}),
|
|
133
151
|
hover: tool({
|
|
@@ -1148,11 +1166,11 @@ export async function failedToolResult(action, message, data, error, judge, inte
|
|
|
1148
1166
|
result.suggestion = getMultipleElementsSuggestion();
|
|
1149
1167
|
result.multipleElementsDetected = true;
|
|
1150
1168
|
result.elements = formatElementList(matched);
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1169
|
+
if (!judge || !matched)
|
|
1170
|
+
return result;
|
|
1171
|
+
const index = await judge.pick(PICK_ELEMENT_QUESTION, matched.map((el) => el.label), { intent });
|
|
1172
|
+
if (!index)
|
|
1154
1173
|
return result;
|
|
1155
|
-
const index = labels.indexOf(pick.value) + 1;
|
|
1156
1174
|
result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
|
|
1157
1175
|
return result;
|
|
1158
1176
|
}
|
|
@@ -1170,6 +1188,7 @@ function getMultipleElementsSuggestion() {
|
|
|
1170
1188
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1171
1189
|
A match reported as not visible can never be acted on — pick one that is.
|
|
1172
1190
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1191
|
+
If the matches cannot be told apart, click the one you mean by appearance with visualClick().
|
|
1173
1192
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1174
1193
|
`;
|
|
1175
1194
|
}
|
|
@@ -1182,7 +1201,7 @@ export function withdrawVisionTools(tools) {
|
|
|
1182
1201
|
export function clickFailureSuggestion(attempts) {
|
|
1183
1202
|
const errors = attempts.map((a) => a.error || '');
|
|
1184
1203
|
if (errors.some((e) => e.includes('not enabled'))) {
|
|
1185
|
-
return 'Element exists but is DISABLED — clicking it again cannot work.
|
|
1204
|
+
return 'Element exists but is DISABLED — clicking it again cannot work. Find the precondition the page shows as unmet, then retry.';
|
|
1186
1205
|
}
|
|
1187
1206
|
if (errors.some((e) => e.includes('intercepts pointer events'))) {
|
|
1188
1207
|
return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
|
|
@@ -1206,6 +1225,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
|
|
|
1206
1225
|
const MAX_DISAMBIGUATE_TEXT = 80;
|
|
1207
1226
|
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1208
1227
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1228
|
+
const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
|
|
1209
1229
|
async function extractWebElements(error) {
|
|
1210
1230
|
if (!error || error.name !== 'MultipleElementsFound')
|
|
1211
1231
|
return null;
|
|
@@ -1219,7 +1239,7 @@ async function extractWebElements(error) {
|
|
|
1219
1239
|
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1220
1240
|
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1221
1241
|
const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
|
|
1222
|
-
result.push({ xpath, html, text, visible });
|
|
1242
|
+
result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
|
|
1223
1243
|
}
|
|
1224
1244
|
catch (e) {
|
|
1225
1245
|
debugLog('Failed to get details for element %d: %s', i, e);
|
package/dist/src/config.d.ts
CHANGED
|
@@ -162,6 +162,7 @@ interface AIConfig {
|
|
|
162
162
|
model: string;
|
|
163
163
|
tool?: boolean;
|
|
164
164
|
direct?: boolean;
|
|
165
|
+
threshold?: number;
|
|
165
166
|
};
|
|
166
167
|
retryAttempts?: number;
|
|
167
168
|
retryDelay?: number;
|
|
@@ -242,6 +243,7 @@ export declare class ConfigParser {
|
|
|
242
243
|
site: SiteRecord | null;
|
|
243
244
|
siteStartPath: string;
|
|
244
245
|
siteConfigPath: string | null;
|
|
246
|
+
runBase: RunBase | null;
|
|
245
247
|
constructor();
|
|
246
248
|
static loadEnv(filePath: string, keepExisting?: boolean): void;
|
|
247
249
|
static recommendedModels(): Record<string, Record<string, string>>;
|
|
@@ -260,6 +262,9 @@ export declare class ConfigParser {
|
|
|
260
262
|
isGlobalMode(): boolean;
|
|
261
263
|
getSite(): SiteRecord | null;
|
|
262
264
|
getSiteConfigPath(): string | null;
|
|
265
|
+
applyBasePath(target: string): string;
|
|
266
|
+
getBaseQuery(): string;
|
|
267
|
+
getBasePath(): string;
|
|
263
268
|
resolveTargetPath(target?: string): string;
|
|
264
269
|
getStatesDir(): string;
|
|
265
270
|
getPlansDir(): string;
|
|
@@ -274,9 +279,7 @@ export declare class ConfigParser {
|
|
|
274
279
|
buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
|
|
275
280
|
findConfigFile(): string | null;
|
|
276
281
|
loadConfigModule(configPath: string): Promise<any>;
|
|
277
|
-
resolveConfig(config: ExplorbotConfig
|
|
278
|
-
baseUrl?: string;
|
|
279
|
-
}): ExplorbotConfig;
|
|
282
|
+
resolveConfig(config: ExplorbotConfig): ExplorbotConfig;
|
|
280
283
|
validateConfig(config: ExplorbotConfig): void;
|
|
281
284
|
getNestedValue(obj: any, path: string): any;
|
|
282
285
|
mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig;
|
|
@@ -301,6 +304,11 @@ export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean):
|
|
|
301
304
|
export declare function materializeKnowledge(outputRoot: string): void;
|
|
302
305
|
export declare function createModel(provider: string, modelId: string): Promise<any>;
|
|
303
306
|
type ModelRole = 'model' | 'visionModel' | 'agenticModel';
|
|
307
|
+
interface RunBase {
|
|
308
|
+
origin: string;
|
|
309
|
+
path: string;
|
|
310
|
+
query: string;
|
|
311
|
+
}
|
|
304
312
|
interface ConfiguredModel {
|
|
305
313
|
name: string;
|
|
306
314
|
provider: string;
|