explorbot 0.5.1 → 0.5.2
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/dist/package.json +1 -1
- package/dist/src/action.d.ts +1 -0
- package/dist/src/action.js +7 -0
- package/dist/src/ai/judge-provider.js +5 -0
- 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 +17 -4
- package/dist/src/ai/provider.js +14 -7
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +11 -4
- package/dist/src/ai/tools.js +18 -9
- package/dist/src/config.d.ts +1 -0
- package/docs/basics/providers.md +3 -2
- package/docs/web-testing/decisions.md +18 -7
- package/package.json +1 -1
- package/src/action.ts +8 -0
- package/src/ai/judge-provider.ts +6 -0
- package/src/ai/judge.ts +25 -6
- package/src/ai/pilot.ts +17 -4
- package/src/ai/provider.ts +13 -7
- package/src/ai/tester.ts +11 -3
- package/src/ai/tools.ts +22 -11
- package/src/config.ts +1 -1
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
|
@@ -461,6 +461,13 @@ class Action {
|
|
|
461
461
|
return false;
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
|
+
async attemptExactElementIndex(codeBlock, elementIndex, originalMessage) {
|
|
465
|
+
const applyElementIndex = (step) => {
|
|
466
|
+
step.opts = { ...step.opts, elementIndex };
|
|
467
|
+
};
|
|
468
|
+
codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
|
|
469
|
+
return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
|
|
470
|
+
}
|
|
464
471
|
async exitIframe() {
|
|
465
472
|
if (!this.playwrightHelper.frame)
|
|
466
473
|
return;
|
|
@@ -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 {
|
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}
|
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/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;
|
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)) {
|
|
@@ -127,7 +134,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
127
134
|
...toolResult,
|
|
128
135
|
attempts,
|
|
129
136
|
suggestion,
|
|
130
|
-
}, ambiguityError || action.lastError
|
|
137
|
+
}, ambiguityError || action.lastError);
|
|
131
138
|
},
|
|
132
139
|
}),
|
|
133
140
|
hover: tool({
|
|
@@ -1148,11 +1155,11 @@ export async function failedToolResult(action, message, data, error, judge, inte
|
|
|
1148
1155
|
result.suggestion = getMultipleElementsSuggestion();
|
|
1149
1156
|
result.multipleElementsDetected = true;
|
|
1150
1157
|
result.elements = formatElementList(matched);
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1158
|
+
if (!judge || !matched)
|
|
1159
|
+
return result;
|
|
1160
|
+
const index = await judge.pick(PICK_ELEMENT_QUESTION, matched.map((el) => el.label), { intent });
|
|
1161
|
+
if (!index)
|
|
1154
1162
|
return result;
|
|
1155
|
-
const index = labels.indexOf(pick.value) + 1;
|
|
1156
1163
|
result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
|
|
1157
1164
|
return result;
|
|
1158
1165
|
}
|
|
@@ -1170,6 +1177,7 @@ function getMultipleElementsSuggestion() {
|
|
|
1170
1177
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1171
1178
|
A match reported as not visible can never be acted on — pick one that is.
|
|
1172
1179
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1180
|
+
If the matches cannot be told apart, click the one you mean by appearance with visualClick().
|
|
1173
1181
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1174
1182
|
`;
|
|
1175
1183
|
}
|
|
@@ -1206,6 +1214,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
|
|
|
1206
1214
|
const MAX_DISAMBIGUATE_TEXT = 80;
|
|
1207
1215
|
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1208
1216
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1217
|
+
const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
|
|
1209
1218
|
async function extractWebElements(error) {
|
|
1210
1219
|
if (!error || error.name !== 'MultipleElementsFound')
|
|
1211
1220
|
return null;
|
|
@@ -1219,7 +1228,7 @@ async function extractWebElements(error) {
|
|
|
1219
1228
|
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1220
1229
|
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1221
1230
|
const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
|
|
1222
|
-
result.push({ xpath, html, text, visible });
|
|
1231
|
+
result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
|
|
1223
1232
|
}
|
|
1224
1233
|
catch (e) {
|
|
1225
1234
|
debugLog('Failed to get details for element %d: %s', i, e);
|
package/dist/src/config.d.ts
CHANGED
package/docs/basics/providers.md
CHANGED
|
@@ -332,6 +332,7 @@ export default {
|
|
|
332
332
|
| `model` | `string` | Model ID at that provider |
|
|
333
333
|
| `tool` | `boolean` | Give Tester and Pilot a `judge` tool to ask their own questions. Default: `true` |
|
|
334
334
|
| `direct` | `boolean` | Consult the model at Explorbot's built-in decision points. Default: `true` |
|
|
335
|
+
| `threshold` | `number` | How sure the model must be before Explorbot acts on an answer, between 0 and 1. Default: `0.7` |
|
|
335
336
|
|
|
336
337
|
Without a config file, or to turn it on for one run, set `EXPLORBOT_DECISION_MODEL` or pass `--decision-model` as `provider/model-id`. It overrides `provider` and `model` in the config and keeps `tool` and `direct`:
|
|
337
338
|
|
|
@@ -340,9 +341,9 @@ EXPLORBOT_DECISION_MODEL=openrouter/typesafe/jev-1.13 npx explorbot explore
|
|
|
340
341
|
npx prima-cli go "Settings" --decision-model typesafe/jev-latest
|
|
341
342
|
```
|
|
342
343
|
|
|
343
|
-
Leave `decisionModel` unset and Explorbot never calls it. An unknown provider
|
|
344
|
+
Leave `decisionModel` unset and Explorbot never calls it. An unknown provider, a missing API key, or a `threshold` outside 0 and 1 stops Explorbot at startup.
|
|
344
345
|
|
|
345
|
-
Explorbot acts on an answer only when it is more than 70%
|
|
346
|
+
Explorbot acts on an answer only when it is more sure than `threshold` (70% by default), and otherwise does what it would have done without the decision model. See [Decisions](../web-testing/decisions.md) for where it is consulted, the `judge` tool, and what happens when an answer is rejected.
|
|
346
347
|
|
|
347
348
|
## Multi-Provider Configuration
|
|
348
349
|
|
|
@@ -13,16 +13,18 @@ Every question has one of two shapes:
|
|
|
13
13
|
| **Statement** | "The page shows that this claim is true." | Probability that the statement holds |
|
|
14
14
|
| **Choice** | "Which listed element does the intent name?" plus numbered options | The chosen option and its probability |
|
|
15
15
|
|
|
16
|
-
A choice always includes an extra *undecided* option, so the model can say that none of the options fits.
|
|
16
|
+
A choice always includes an extra *undecided* option, so the model can say that none of the options fits. The question tells the model to choose it when it is not sure.
|
|
17
17
|
|
|
18
18
|
Explorbot acts on an answer only when it is **approved**:
|
|
19
19
|
|
|
20
|
-
- a statement's probability is above
|
|
21
|
-
- a choice picked a real option, not *undecided*, with a probability above
|
|
20
|
+
- a statement's probability is above the threshold, or
|
|
21
|
+
- a choice picked a real option, not *undecided*, with a probability above the threshold.
|
|
22
|
+
|
|
23
|
+
The threshold is **70%** unless `decisionModel.threshold` sets another value.
|
|
22
24
|
|
|
23
25
|
Anything else counts as **rejected**, including:
|
|
24
26
|
|
|
25
|
-
- a confident "no", or any probability
|
|
27
|
+
- a confident "no", or any probability at or below the threshold
|
|
26
28
|
- *undecided*
|
|
27
29
|
- a choice offered with fewer than two options
|
|
28
30
|
- a request timeout (15 seconds) or a failed request
|
|
@@ -38,12 +40,15 @@ These are fixed places in the code where Explorbot asks the decision model befor
|
|
|
38
40
|
|
|
39
41
|
When a click or fill fails because the locator matched more than one element, Explorbot lists the matches and asks which one the step meant.
|
|
40
42
|
|
|
41
|
-
- **Approved:**
|
|
43
|
+
- **Approved:** a click is repeated right away on the chosen element with `elementIndex`, before the Tester's remaining fallback locators. For other actions, the Tester is told which element to use.
|
|
44
|
+
- **Identical options:** when two matches look the same, the question is not asked. The Tester is told to click by appearance with `visualClick()`.
|
|
42
45
|
- **Fallback:** the Tester receives the numbered list of matches and picks one itself, narrows the locator, or uses a visual click.
|
|
43
46
|
|
|
44
47
|
### Pilot: progress review
|
|
45
48
|
|
|
46
|
-
Every few steps, Pilot reviews the Tester's recent actions with the `agenticModel`.
|
|
49
|
+
Every few steps, Pilot reviews the Tester's recent actions with the `agenticModel`. On a routine review it first asks whether the recent actions advance the scenario toward its remaining expected outcomes. The decision model sees the scenario, its planned steps, which expected outcomes are checked and which remain, the run log, the pages visited, the current page and the recent actions.
|
|
50
|
+
|
|
51
|
+
A review forced by a run of failed or empty steps skips the question and goes straight to Pilot.
|
|
47
52
|
|
|
48
53
|
- **Approved:** the review is skipped and the Tester continues. This saves one `agenticModel` call.
|
|
49
54
|
- **Fallback:** Pilot runs its full review as usual.
|
|
@@ -105,4 +110,10 @@ The model supplies only the question. The tool gathers the rest of the state its
|
|
|
105
110
|
|
|
106
111
|
## Tracing
|
|
107
112
|
|
|
108
|
-
Each question is recorded as a `judge.decide` span
|
|
113
|
+
Each question is recorded as a `judge.decide` span:
|
|
114
|
+
|
|
115
|
+
- **Input:** the exact request body sent to the provider: model, state and question. Post it again to the endpoint to replay the decision.
|
|
116
|
+
- **Output:** the raw answer and its probability, before the threshold is applied.
|
|
117
|
+
- **Metadata:** `judgeQuestion` for filtering by question, `judgeDecision` with the approved value, confidence, threshold and model, and `judgeEndpoint`.
|
|
118
|
+
|
|
119
|
+
A failed request marks the span as an error and records the reason. The TUI shows `⚖️ Asking judge...` while a question is in flight. See [Observability](../contributing/observability.md) for setting up tracing.
|
package/package.json
CHANGED
package/src/action.ts
CHANGED
|
@@ -491,6 +491,14 @@ class Action {
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
public async attemptExactElementIndex(codeBlock: string, elementIndex: number, originalMessage?: string): Promise<boolean> {
|
|
495
|
+
const applyElementIndex = (step: any) => {
|
|
496
|
+
step.opts = { ...step.opts, elementIndex };
|
|
497
|
+
};
|
|
498
|
+
codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
|
|
499
|
+
return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
|
|
500
|
+
}
|
|
501
|
+
|
|
494
502
|
async exitIframe(): Promise<void> {
|
|
495
503
|
if (!this.playwrightHelper.frame) return;
|
|
496
504
|
debugLog('Switching to main frame');
|
package/src/ai/judge-provider.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { Observability } from '../observability.ts';
|
|
2
|
+
import { redactSecrets } from '../utils/secrets.ts';
|
|
3
|
+
|
|
1
4
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
2
5
|
|
|
3
6
|
export class JudgeProvider {
|
|
@@ -21,6 +24,9 @@ export class JudgeProvider {
|
|
|
21
24
|
let q: Record<string, unknown> = { type: 'noul', instructions: question };
|
|
22
25
|
if (options) q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
|
|
23
26
|
const body = JSON.stringify({ model: this.model, state, questions: { q } });
|
|
27
|
+
const span = Observability.getSpan();
|
|
28
|
+
span?.setAttribute('langfuse.observation.input', redactSecrets(body));
|
|
29
|
+
span?.setAttribute('ai.telemetry.metadata.judgeEndpoint', this.url);
|
|
24
30
|
const controller = new AbortController();
|
|
25
31
|
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
26
32
|
try {
|
package/src/ai/judge.ts
CHANGED
|
@@ -9,6 +9,7 @@ const debugLog = createDebug('explorbot:judge');
|
|
|
9
9
|
const APPROVAL_THRESHOLD = 0.7;
|
|
10
10
|
|
|
11
11
|
export const UNDECIDED = 'undecided';
|
|
12
|
+
const UNDECIDED_HINT = `Choose "${UNDECIDED}" if you are not sure or no option fits.`;
|
|
12
13
|
export const JUDGE_PAGE_CAP = 12000;
|
|
13
14
|
|
|
14
15
|
export class Decision {
|
|
@@ -29,12 +30,15 @@ export class Decision {
|
|
|
29
30
|
export class Judge {
|
|
30
31
|
constructor(
|
|
31
32
|
private provider: JudgeProvider,
|
|
32
|
-
private enabled: { tool: boolean; direct: boolean }
|
|
33
|
+
private enabled: { tool: boolean; direct: boolean },
|
|
34
|
+
private threshold = APPROVAL_THRESHOLD
|
|
33
35
|
) {}
|
|
34
36
|
|
|
35
37
|
static fromConfig(config: AIConfig['decisionModel']): Judge | null {
|
|
36
38
|
if (!config) return null;
|
|
37
|
-
|
|
39
|
+
const threshold = config.threshold ?? APPROVAL_THRESHOLD;
|
|
40
|
+
if (!(threshold > 0 && threshold < 1)) throw new Error(`decisionModel.threshold must be between 0 and 1, got ${config.threshold}`);
|
|
41
|
+
return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false }, threshold);
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
get toolEnabled(): boolean {
|
|
@@ -46,12 +50,21 @@ export class Judge {
|
|
|
46
50
|
return this.consult(question, options, state);
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
async pick(question: string, options: string[], state: unknown): Promise<number | null> {
|
|
54
|
+
if (new Set(options).size < options.length) return null;
|
|
55
|
+
const decision = await this.decide(question, [...options, UNDECIDED], state);
|
|
56
|
+
if (!decision.value) return null;
|
|
57
|
+
return options.indexOf(decision.value) + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
async consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
|
|
50
61
|
if (Array.isArray(options) && options.length < 2) return new Decision(null, 0);
|
|
51
62
|
return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
|
|
52
63
|
setActivity('⚖️ Asking judge...', 'ai');
|
|
53
64
|
const decision = await this.request(question, options, state).finally(() => clearActivity());
|
|
54
|
-
Observability.getSpan()
|
|
65
|
+
const span = Observability.getSpan();
|
|
66
|
+
span?.setAttribute('ai.telemetry.metadata.judgeQuestion', question);
|
|
67
|
+
span?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence, threshold: this.threshold, model: this.provider.model }));
|
|
55
68
|
return decision;
|
|
56
69
|
});
|
|
57
70
|
}
|
|
@@ -59,17 +72,23 @@ export class Judge {
|
|
|
59
72
|
private async request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
|
|
60
73
|
let list: string[] | undefined;
|
|
61
74
|
if (Array.isArray(options)) list = options;
|
|
75
|
+
let instructions = question;
|
|
76
|
+
if (list?.includes(UNDECIDED)) instructions = `${question} ${UNDECIDED_HINT}`;
|
|
62
77
|
|
|
63
|
-
const answer = await this.provider.decide(state,
|
|
78
|
+
const answer = await this.provider.decide(state, instructions, list).catch((error: unknown) => this.recordFailure(error));
|
|
64
79
|
if (!answer) return new Decision(null, 0);
|
|
65
|
-
|
|
80
|
+
Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
|
|
81
|
+
if (answer.probability <= this.threshold) return new Decision(null, answer.probability);
|
|
66
82
|
if (answer.value === UNDECIDED) return new Decision(null, answer.probability);
|
|
67
83
|
return new Decision(answer.value, answer.probability);
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
private recordFailure(error: unknown): null {
|
|
71
87
|
debugLog('judge declined: %s', error);
|
|
72
|
-
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));
|
|
73
92
|
return null;
|
|
74
93
|
}
|
|
75
94
|
}
|
package/src/ai/pilot.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type { Agent, AgentDeps } from './agent.ts';
|
|
|
19
19
|
import type { Conversation } from './conversation.ts';
|
|
20
20
|
import type { Fisherman } from './fisherman.ts';
|
|
21
21
|
import { createAskApiTool } from './fisherman/tools.ts';
|
|
22
|
-
import { type Judge, UNDECIDED } from './judge.ts';
|
|
22
|
+
import { JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
|
|
23
23
|
import type { Navigator } from './navigator.ts';
|
|
24
24
|
import type { Provider } from './provider.ts';
|
|
25
25
|
import type { Researcher } from './researcher.ts';
|
|
@@ -542,6 +542,22 @@ export class Pilot implements Agent {
|
|
|
542
542
|
);
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
async periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string> {
|
|
546
|
+
const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
|
|
547
|
+
const healthy = await this.judge?.decide('The recent actions advance the scenario toward its remaining expected outcomes.', null, {
|
|
548
|
+
scenario: task.scenario,
|
|
549
|
+
plannedSteps: task.plannedSteps,
|
|
550
|
+
expectations: this.formatExpectations(task),
|
|
551
|
+
runLog: task.notesToString() || 'No steps recorded.',
|
|
552
|
+
visitedUrls: task.getVisitedUrls({ localOnly: true }),
|
|
553
|
+
state: this.buildStateContext(currentState),
|
|
554
|
+
page: currentState.getCompactARIA().slice(0, JUDGE_PAGE_CAP),
|
|
555
|
+
recentActions: this.formatActions(toolCalls),
|
|
556
|
+
});
|
|
557
|
+
if (healthy?.approved) return '';
|
|
558
|
+
return this.analyzeProgress(task, currentState, testerConversation);
|
|
559
|
+
}
|
|
560
|
+
|
|
545
561
|
async analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string> {
|
|
546
562
|
tag('substep').log('Pilot analyzing progress...');
|
|
547
563
|
|
|
@@ -555,9 +571,6 @@ export class Pilot implements Agent {
|
|
|
555
571
|
const actionsContext = this.formatActions(toolCalls);
|
|
556
572
|
const stateContext = this.buildStateContext(currentState);
|
|
557
573
|
|
|
558
|
-
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 });
|
|
559
|
-
if (healthy?.approved) return '';
|
|
560
|
-
|
|
561
574
|
const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
|
|
562
575
|
|
|
563
576
|
const text = await this.sendToPilot(
|
package/src/ai/provider.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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';
|
|
7
6
|
import type { ModelMessage } from 'ai';
|
|
7
|
+
import dedent from 'dedent';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
10
10
|
import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
|
|
@@ -60,14 +60,15 @@ function extractCachedTokens(usage: any): number {
|
|
|
60
60
|
return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function abortAfterIdle(ms: number,
|
|
63
|
+
function abortAfterIdle(ms: number, state: { cancelled: boolean; timedOut: boolean }, controller: AbortController, busy: { tools: number }): Promise<never> {
|
|
64
64
|
return new Promise((_, reject) => {
|
|
65
65
|
const tick = () => {
|
|
66
|
-
if (
|
|
66
|
+
if (state.cancelled) return;
|
|
67
67
|
if (executionController.isAwaitingInput() || busy.tools > 0) {
|
|
68
68
|
setTimeout(tick, ms);
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
|
+
state.timedOut = true;
|
|
71
72
|
controller.abort();
|
|
72
73
|
reject(new Error('AI request timeout'));
|
|
73
74
|
};
|
|
@@ -252,13 +253,18 @@ export class Provider {
|
|
|
252
253
|
}
|
|
253
254
|
|
|
254
255
|
private async raceWithIdleTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, timeoutMs: number, busy: { tools: number } = { tools: 0 }): Promise<T> {
|
|
255
|
-
const
|
|
256
|
+
const state = { cancelled: false, timedOut: false };
|
|
256
257
|
const controller = new AbortController();
|
|
257
258
|
const combinedSignal = combinedAbortSignal(controller);
|
|
258
259
|
try {
|
|
259
|
-
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs,
|
|
260
|
+
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, state, controller, busy)]);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (state.timedOut && !executionController.getAbortSignal()?.aborted) {
|
|
263
|
+
throw new Error('AI request timeout', { cause: error });
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
260
266
|
} finally {
|
|
261
|
-
|
|
267
|
+
state.cancelled = true;
|
|
262
268
|
}
|
|
263
269
|
}
|
|
264
270
|
|
package/src/ai/tester.ts
CHANGED
|
@@ -324,7 +324,12 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
324
324
|
const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
|
|
325
325
|
if (guidance) nextStep += `\n\n${guidance}`;
|
|
326
326
|
} else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
|
|
327
|
-
|
|
327
|
+
let guidance: string;
|
|
328
|
+
if (this.isStruggling) {
|
|
329
|
+
guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
|
|
330
|
+
} else {
|
|
331
|
+
guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
|
|
332
|
+
}
|
|
328
333
|
if (guidance) nextStep += `\n\n${guidance}`;
|
|
329
334
|
this.consecutiveFailures = 0;
|
|
330
335
|
this.lastAnalyzedStateHash = currentState.hash;
|
|
@@ -486,13 +491,16 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
486
491
|
this.regionTransitioned = false;
|
|
487
492
|
return true;
|
|
488
493
|
}
|
|
489
|
-
if (this.
|
|
490
|
-
if (this.consecutiveEmptyResults >= 2) return true;
|
|
494
|
+
if (this.isStruggling) return true;
|
|
491
495
|
if (iteration % this.progressCheckInterval !== 0) return false;
|
|
492
496
|
if (this.lastAnalyzedStateHash === currentState.hash) return false;
|
|
493
497
|
return true;
|
|
494
498
|
}
|
|
495
499
|
|
|
500
|
+
private get isStruggling(): boolean {
|
|
501
|
+
return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
|
|
502
|
+
}
|
|
503
|
+
|
|
496
504
|
private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
|
|
497
505
|
if (task.hasFinished) return false;
|
|
498
506
|
|
package/src/ai/tools.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/str
|
|
|
16
16
|
import { WebElement } from '../utils/web-element.ts';
|
|
17
17
|
import type { ToolDeps } from './agent.ts';
|
|
18
18
|
import { createJudgeTool } from './judge-tool.ts';
|
|
19
|
-
import { JUDGE_PAGE_CAP, type Judge
|
|
19
|
+
import { JUDGE_PAGE_CAP, type Judge } from './judge.ts';
|
|
20
20
|
import { Navigator } from './navigator.ts';
|
|
21
21
|
import { Researcher } from './researcher.ts';
|
|
22
22
|
import { sectionContextRule } from './rules.ts';
|
|
@@ -113,13 +113,20 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
|
|
|
113
113
|
|
|
114
114
|
for (let i = 0; i < commands.length; i++) {
|
|
115
115
|
const command = transformContainsCommand(commands[i]);
|
|
116
|
-
|
|
116
|
+
let success = await action.attempt(command, explanation);
|
|
117
117
|
|
|
118
118
|
const attempt: { command: string; success: boolean; error?: string } = { command, success };
|
|
119
119
|
if (action.lastError) attempt.error = errorText(action.lastError);
|
|
120
120
|
attempts.push(attempt);
|
|
121
121
|
|
|
122
|
-
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound')
|
|
122
|
+
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') {
|
|
123
|
+
ambiguityError = action.lastError;
|
|
124
|
+
if (judge) {
|
|
125
|
+
const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
|
|
126
|
+
const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
|
|
127
|
+
if (index) success = await action.attemptExactElementIndex(command, index, explanation);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
123
130
|
|
|
124
131
|
if (success) {
|
|
125
132
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
@@ -158,9 +165,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
|
|
|
158
165
|
attempts,
|
|
159
166
|
suggestion,
|
|
160
167
|
},
|
|
161
|
-
ambiguityError || action.lastError
|
|
162
|
-
judge,
|
|
163
|
-
explanation
|
|
168
|
+
ambiguityError || action.lastError
|
|
164
169
|
);
|
|
165
170
|
},
|
|
166
171
|
}),
|
|
@@ -1309,10 +1314,13 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1309
1314
|
result.suggestion = getMultipleElementsSuggestion();
|
|
1310
1315
|
result.multipleElementsDetected = true;
|
|
1311
1316
|
result.elements = formatElementList(matched);
|
|
1312
|
-
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1317
|
+
if (!judge || !matched) return result;
|
|
1318
|
+
const index = await judge.pick(
|
|
1319
|
+
PICK_ELEMENT_QUESTION,
|
|
1320
|
+
matched.map((el) => el.label),
|
|
1321
|
+
{ intent }
|
|
1322
|
+
);
|
|
1323
|
+
if (!index) return result;
|
|
1316
1324
|
result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
|
|
1317
1325
|
return result;
|
|
1318
1326
|
}
|
|
@@ -1333,6 +1341,7 @@ function getMultipleElementsSuggestion(): string {
|
|
|
1333
1341
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1334
1342
|
A match reported as not visible can never be acted on — pick one that is.
|
|
1335
1343
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1344
|
+
If the matches cannot be told apart, click the one you mean by appearance with visualClick().
|
|
1336
1345
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1337
1346
|
`;
|
|
1338
1347
|
}
|
|
@@ -1379,6 +1388,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
|
|
|
1379
1388
|
const MAX_DISAMBIGUATE_TEXT = 80;
|
|
1380
1389
|
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1381
1390
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1391
|
+
const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
|
|
1382
1392
|
|
|
1383
1393
|
async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
|
|
1384
1394
|
if (!error || error.name !== 'MultipleElementsFound') return null;
|
|
@@ -1393,7 +1403,7 @@ async function extractWebElements(error: Error | null | undefined): Promise<Matc
|
|
|
1393
1403
|
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1394
1404
|
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1395
1405
|
const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
|
|
1396
|
-
result.push({ xpath, html, text, visible });
|
|
1406
|
+
result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
|
|
1397
1407
|
} catch (e) {
|
|
1398
1408
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
1399
1409
|
}
|
|
@@ -1443,5 +1453,6 @@ interface MatchedElement {
|
|
|
1443
1453
|
xpath: string;
|
|
1444
1454
|
html: string;
|
|
1445
1455
|
text: string;
|
|
1456
|
+
label: string;
|
|
1446
1457
|
visible?: boolean;
|
|
1447
1458
|
}
|
package/src/config.ts
CHANGED
|
@@ -185,7 +185,7 @@ interface AIConfig {
|
|
|
185
185
|
vision?: boolean;
|
|
186
186
|
visionModel?: any;
|
|
187
187
|
agenticModel?: any;
|
|
188
|
-
decisionModel?: { provider: string; model: string; tool?: boolean; direct?: boolean };
|
|
188
|
+
decisionModel?: { provider: string; model: string; tool?: boolean; direct?: boolean; threshold?: number };
|
|
189
189
|
retryAttempts?: number;
|
|
190
190
|
retryDelay?: number;
|
|
191
191
|
maxParallelRequests?: number;
|