explorbot 0.3.0 → 0.3.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/bin/explorbot-cli.ts +3 -3
- package/dist/bin/explorbot-cli.js +3 -2
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +3 -0
- package/dist/src/action.d.ts +1 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/conversation.d.ts +1 -0
- package/dist/src/ai/conversation.js +3 -0
- package/dist/src/ai/driller.js +3 -1
- package/dist/src/ai/fisherman.js +1 -1
- package/dist/src/ai/pilot.js +14 -9
- package/dist/src/ai/provider.d.ts +5 -0
- package/dist/src/ai/provider.js +84 -19
- package/dist/src/ai/researcher/deep-analysis.js +2 -2
- package/dist/src/ai/tester.js +22 -6
- package/dist/src/commands/exit-command.js +1 -1
- package/dist/src/commands/explore-command.js +22 -17
- package/dist/src/config.d.ts +2 -1
- package/dist/src/explorbot.js +1 -0
- package/dist/src/state-manager.d.ts +2 -0
- package/dist/src/state-manager.js +3 -3
- package/dist/src/utils/html.d.ts +2 -1
- package/dist/src/utils/html.js +10 -4
- package/dist/src/utils/overlay.d.ts +24 -0
- package/dist/src/utils/overlay.js +43 -0
- package/package.json +1 -1
- package/src/action-result.ts +5 -0
- package/src/action.ts +15 -1
- package/src/ai/conversation.ts +3 -0
- package/src/ai/driller.ts +3 -1
- package/src/ai/fisherman.ts +1 -1
- package/src/ai/pilot.ts +15 -10
- package/src/ai/provider.ts +115 -43
- package/src/ai/researcher/deep-analysis.ts +2 -2
- package/src/ai/tester.ts +20 -7
- package/src/commands/exit-command.ts +1 -1
- package/src/commands/explore-command.ts +17 -14
- package/src/config.ts +2 -1
- package/src/explorbot.ts +1 -0
- package/src/state-manager.ts +4 -3
- package/src/utils/html.ts +13 -4
- package/src/utils/overlay.ts +51 -0
package/src/ai/provider.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
2
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
-
import {
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
|
|
5
6
|
import type { ModelMessage } from 'ai';
|
|
7
|
+
import { z } from 'zod';
|
|
6
8
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
7
9
|
import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
|
|
8
10
|
import { executionController } from '../execution-controller.ts';
|
|
@@ -11,7 +13,7 @@ import { Stats } from '../stats.ts';
|
|
|
11
13
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
14
|
import { type RetryOptions, withRetry } from '../utils/retry.js';
|
|
13
15
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
14
|
-
import { Conversation, toToolExecution } from './conversation.js';
|
|
16
|
+
import { Conversation, NARRATION_TOOL, toToolExecution } from './conversation.js';
|
|
15
17
|
|
|
16
18
|
const debugLog = createDebug('explorbot:provider');
|
|
17
19
|
const promptLog = createDebug('explorbot:provider:out');
|
|
@@ -20,7 +22,30 @@ const responseLog = createDebug('explorbot:provider:in');
|
|
|
20
22
|
class AiError extends Error {}
|
|
21
23
|
export class ContextLengthError extends Error {}
|
|
22
24
|
|
|
25
|
+
const DEFAULT_PARALLEL_REQUESTS = 4;
|
|
26
|
+
|
|
27
|
+
const modelSlotContext = new AsyncLocalStorage<boolean>();
|
|
28
|
+
|
|
29
|
+
const HARMONY_CHANNELS = ['commentary', 'analysis', 'final'];
|
|
30
|
+
|
|
31
|
+
function createHarmonyChannelFallbackTool() {
|
|
32
|
+
return tool({
|
|
33
|
+
description: 'Internal compatibility fallback for model channel output. Do not call directly.',
|
|
34
|
+
inputSchema: z.record(z.string(), z.any()),
|
|
35
|
+
execute: async () => ({ message: 'Noted. Continue with your next action.' }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
let telemetryRegistered = false;
|
|
40
|
+
let beforeExitFlushHooked = false;
|
|
41
|
+
let activeOtelSdk: NodeSDK | null = null;
|
|
42
|
+
|
|
43
|
+
export async function flushTelemetry(): Promise<void> {
|
|
44
|
+
const sdk = activeOtelSdk;
|
|
45
|
+
activeOtelSdk = null;
|
|
46
|
+
if (!sdk) return;
|
|
47
|
+
await sdk.shutdown().catch((error) => debugLog(`Telemetry flush failed: ${error instanceof Error ? error.message : error}`));
|
|
48
|
+
}
|
|
24
49
|
|
|
25
50
|
const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
|
|
26
51
|
|
|
@@ -79,6 +104,8 @@ export class Provider {
|
|
|
79
104
|
};
|
|
80
105
|
|
|
81
106
|
lastConversation: Conversation | null = null;
|
|
107
|
+
private activeModelCalls = 0;
|
|
108
|
+
private modelCallWaiters: (() => void)[] = [];
|
|
82
109
|
|
|
83
110
|
constructor(config: AIConfig) {
|
|
84
111
|
if (!config?.model) {
|
|
@@ -99,6 +126,10 @@ export class Provider {
|
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
|
|
129
|
+
async stop(): Promise<void> {
|
|
130
|
+
await flushTelemetry();
|
|
131
|
+
}
|
|
132
|
+
|
|
102
133
|
getModelForAgent(agentName?: string): any {
|
|
103
134
|
if (!agentName) {
|
|
104
135
|
return this.config.model;
|
|
@@ -154,10 +185,28 @@ export class Provider {
|
|
|
154
185
|
private getRetryOptions(options: any = {}): RetryOptions {
|
|
155
186
|
return {
|
|
156
187
|
...this.defaultRetryOptions,
|
|
157
|
-
maxAttempts: options.maxRetries || this.defaultRetryOptions.maxAttempts,
|
|
188
|
+
maxAttempts: options.maxRetries || this.config.retryAttempts || this.defaultRetryOptions.maxAttempts,
|
|
189
|
+
baseDelay: this.config.retryDelay || this.defaultRetryOptions.baseDelay,
|
|
158
190
|
};
|
|
159
191
|
}
|
|
160
192
|
|
|
193
|
+
private async withModelRequestSlot<T>(fn: () => Promise<T>): Promise<T> {
|
|
194
|
+
if (modelSlotContext.getStore()) return fn();
|
|
195
|
+
const limit = Math.max(1, this.config.maxParallelRequests ?? DEFAULT_PARALLEL_REQUESTS);
|
|
196
|
+
if (this.activeModelCalls >= limit || this.modelCallWaiters.length > 0) {
|
|
197
|
+
await new Promise<void>((resolve) => this.modelCallWaiters.push(resolve));
|
|
198
|
+
} else {
|
|
199
|
+
this.activeModelCalls++;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
return await modelSlotContext.run(true, fn);
|
|
203
|
+
} finally {
|
|
204
|
+
const next = this.modelCallWaiters.shift();
|
|
205
|
+
if (next) next();
|
|
206
|
+
else this.activeModelCalls--;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
161
210
|
private mergeProviderOptions(config: Record<string, any>, agentName?: string): Record<string, any> {
|
|
162
211
|
if (!agentName) return config;
|
|
163
212
|
const agentOptions = this.getProviderOptionsForAgent(agentName);
|
|
@@ -234,7 +283,12 @@ export class Provider {
|
|
|
234
283
|
spanProcessors: [processor],
|
|
235
284
|
instrumentations: [],
|
|
236
285
|
});
|
|
286
|
+
activeOtelSdk = this.otelSdk;
|
|
237
287
|
void this.otelSdk.start();
|
|
288
|
+
if (!beforeExitFlushHooked) {
|
|
289
|
+
process.on('beforeExit', () => void flushTelemetry());
|
|
290
|
+
beforeExitFlushHooked = true;
|
|
291
|
+
}
|
|
238
292
|
if (!telemetryRegistered) {
|
|
239
293
|
registerTelemetry(new OpenTelemetry());
|
|
240
294
|
telemetryRegistered = true;
|
|
@@ -288,7 +342,7 @@ export class Provider {
|
|
|
288
342
|
async invokeConversation(conversation: Conversation, tools?: any, options: any = {}): Promise<{ conversation: Conversation; response: any; toolExecutions?: any[] } | null> {
|
|
289
343
|
const response = tools ? await this.generateWithTools(conversation.messages, conversation.model, tools, options) : await this.chat(conversation.messages, conversation.model, options);
|
|
290
344
|
|
|
291
|
-
const responseMessages = response.
|
|
345
|
+
const responseMessages = response.responseMessages || [];
|
|
292
346
|
if (responseMessages.length > 0) {
|
|
293
347
|
conversation.messages.push(...responseMessages);
|
|
294
348
|
tag('debug').log('Added', responseMessages.length, 'messages from response');
|
|
@@ -302,7 +356,7 @@ export class Provider {
|
|
|
302
356
|
const toolResults = response.toolResults || [];
|
|
303
357
|
|
|
304
358
|
const resultsById = new Map(toolResults.map((r: any) => [r.toolCallId, r]));
|
|
305
|
-
const toolExecutions = toolCalls.map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
359
|
+
const toolExecutions = toolCalls.filter((call: any) => call.toolName !== NARRATION_TOOL).map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
306
360
|
|
|
307
361
|
return { conversation, response, toolExecutions };
|
|
308
362
|
}
|
|
@@ -316,21 +370,23 @@ export class Provider {
|
|
|
316
370
|
|
|
317
371
|
promptLog(messages[messages.length - 1].content);
|
|
318
372
|
try {
|
|
319
|
-
const response = await
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
373
|
+
const response = await this.withModelRequestSlot(() =>
|
|
374
|
+
withRetry(async () => {
|
|
375
|
+
const result = await generateText({ messages, ...config });
|
|
376
|
+
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
377
|
+
if (!result.text) {
|
|
378
|
+
debugLog(result);
|
|
379
|
+
if (result.finishReason === 'length') {
|
|
380
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
381
|
+
}
|
|
382
|
+
throw new Error('No response text from AI');
|
|
383
|
+
}
|
|
324
384
|
if (result.finishReason === 'length') {
|
|
325
|
-
|
|
385
|
+
debugLog('finishReason=length, response may be truncated');
|
|
326
386
|
}
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
debugLog('finishReason=length, response may be truncated');
|
|
331
|
-
}
|
|
332
|
-
return result;
|
|
333
|
-
}, this.getRetryOptions(options));
|
|
387
|
+
return result;
|
|
388
|
+
}, this.getRetryOptions(options))
|
|
389
|
+
);
|
|
334
390
|
|
|
335
391
|
clearActivity();
|
|
336
392
|
responseLog(response.text);
|
|
@@ -356,7 +412,8 @@ export class Provider {
|
|
|
356
412
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
357
413
|
promptLog(`Using model: ${modelName}`);
|
|
358
414
|
|
|
359
|
-
const
|
|
415
|
+
const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
416
|
+
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
360
417
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
361
418
|
promptLog('Available tools:', toolNames);
|
|
362
419
|
promptLog(messages[messages.length - 1].content);
|
|
@@ -365,17 +422,19 @@ export class Provider {
|
|
|
365
422
|
const extraStop = options.stopWhen;
|
|
366
423
|
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
367
424
|
if (extraStop) stopConditions.push(extraStop);
|
|
368
|
-
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
425
|
+
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
369
426
|
try {
|
|
370
|
-
const response = await
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
427
|
+
const response = await this.withModelRequestSlot(() =>
|
|
428
|
+
withRetry(async () => {
|
|
429
|
+
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
|
|
430
|
+
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
431
|
+
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
432
|
+
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
433
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
434
|
+
}
|
|
435
|
+
return result;
|
|
436
|
+
}, this.getRetryOptions(options))
|
|
437
|
+
);
|
|
379
438
|
|
|
380
439
|
clearActivity();
|
|
381
440
|
|
|
@@ -418,9 +477,11 @@ export class Provider {
|
|
|
418
477
|
|
|
419
478
|
try {
|
|
420
479
|
promptLog(messages[messages.length - 1].content);
|
|
421
|
-
const response = await
|
|
422
|
-
|
|
423
|
-
|
|
480
|
+
const response = await this.withModelRequestSlot(() =>
|
|
481
|
+
withRetry(async () => {
|
|
482
|
+
return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
|
|
483
|
+
}, this.getRetryOptions(options))
|
|
484
|
+
);
|
|
424
485
|
|
|
425
486
|
clearActivity();
|
|
426
487
|
responseLog(response.object);
|
|
@@ -572,8 +633,6 @@ export class Provider {
|
|
|
572
633
|
|
|
573
634
|
setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
|
|
574
635
|
|
|
575
|
-
const imageData = `data:image/png;base64,${image.toString()}`;
|
|
576
|
-
|
|
577
636
|
const messages: ModelMessage[] = [
|
|
578
637
|
{
|
|
579
638
|
role: 'user',
|
|
@@ -585,7 +644,7 @@ export class Provider {
|
|
|
585
644
|
{
|
|
586
645
|
type: 'file',
|
|
587
646
|
mediaType: 'image/png',
|
|
588
|
-
data:
|
|
647
|
+
data: image,
|
|
589
648
|
},
|
|
590
649
|
],
|
|
591
650
|
},
|
|
@@ -602,12 +661,14 @@ export class Provider {
|
|
|
602
661
|
|
|
603
662
|
try {
|
|
604
663
|
promptLog(`Processing image with prompt: ${prompt}`);
|
|
605
|
-
const response = await
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
664
|
+
const response = await this.withModelRequestSlot(() =>
|
|
665
|
+
withRetry(async () => {
|
|
666
|
+
return await generateText({
|
|
667
|
+
messages,
|
|
668
|
+
...config,
|
|
669
|
+
});
|
|
670
|
+
}, this.getRetryOptions())
|
|
671
|
+
);
|
|
611
672
|
|
|
612
673
|
clearActivity();
|
|
613
674
|
responseLog(response.text);
|
|
@@ -623,13 +684,13 @@ export class Provider {
|
|
|
623
684
|
}
|
|
624
685
|
|
|
625
686
|
hasVision(): boolean {
|
|
626
|
-
return this.config.visionModel !== undefined;
|
|
687
|
+
return this.config.visionModel !== undefined && !Stats.visionDisabled;
|
|
627
688
|
}
|
|
628
689
|
}
|
|
629
690
|
|
|
630
691
|
function repairToolCall(options: ToolCallRepairOptions): any | null {
|
|
631
692
|
if (options.toolCall.toolName.includes('<|channel|>')) return repairChannelMarker(options);
|
|
632
|
-
return
|
|
693
|
+
return repairHarmonyChannel(options);
|
|
633
694
|
}
|
|
634
695
|
|
|
635
696
|
function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
@@ -641,6 +702,17 @@ function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any |
|
|
|
641
702
|
return { ...toolCall, toolName };
|
|
642
703
|
}
|
|
643
704
|
|
|
705
|
+
function repairHarmonyChannel({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
706
|
+
if (!HARMONY_CHANNELS.includes(toolCall.toolName)) return null;
|
|
707
|
+
if (!tools.commentary) return null;
|
|
708
|
+
let input = toolCall.input;
|
|
709
|
+
if (typeof input !== 'string' || !input.trim().startsWith('{')) {
|
|
710
|
+
input = JSON.stringify({ content: typeof input === 'string' ? input : JSON.stringify(input ?? null) });
|
|
711
|
+
}
|
|
712
|
+
tag('warning').log(`Repaired tool name '${toolCall.toolName}' → 'commentary'`);
|
|
713
|
+
return { ...toolCall, toolName: NARRATION_TOOL, input };
|
|
714
|
+
}
|
|
715
|
+
|
|
644
716
|
export { AiError, Provider as AIProvider };
|
|
645
717
|
|
|
646
718
|
type ToolCallRepairOptions = { toolCall: any; tools: any };
|
|
@@ -5,7 +5,7 @@ import { executionController } from '../../execution-controller.ts';
|
|
|
5
5
|
import type Explorer from '../../explorer.ts';
|
|
6
6
|
import type { StateManager } from '../../state-manager.js';
|
|
7
7
|
import { WebPageState } from '../../state-manager.js';
|
|
8
|
-
import {
|
|
8
|
+
import { diffAriaSnapshots } from '../../utils/aria.ts';
|
|
9
9
|
import { extractCodeBlocks } from '../../utils/code-extractor.ts';
|
|
10
10
|
import { tag } from '../../utils/logger.js';
|
|
11
11
|
import { mdq } from '../../utils/markdown-query.ts';
|
|
@@ -86,7 +86,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
89
|
-
const focusArea =
|
|
89
|
+
const focusArea = current.overlay;
|
|
90
90
|
if (!focusArea.detected || !focusArea.name) return null;
|
|
91
91
|
if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
|
|
92
92
|
|
package/src/ai/tester.ts
CHANGED
|
@@ -8,10 +8,9 @@ import { clearActivity, setActivity } from '../activity.ts';
|
|
|
8
8
|
import type { RequestStore } from '../api/request-store.ts';
|
|
9
9
|
import type { TestRun } from '../explorer.ts';
|
|
10
10
|
import { Observability } from '../observability.ts';
|
|
11
|
-
import type
|
|
11
|
+
import { type StateTransition, normalizeUrl } from '../state-manager.ts';
|
|
12
12
|
import { Stats } from '../stats.ts';
|
|
13
13
|
import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
|
|
14
|
-
import { detectFocusArea } from '../utils/aria.ts';
|
|
15
14
|
import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
|
|
16
15
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
17
16
|
import { loop } from '../utils/loop.ts';
|
|
@@ -100,7 +99,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
100
99
|
|
|
101
100
|
async test(task: Test, opts: TestOptions = {}): Promise<{ success: boolean }> {
|
|
102
101
|
Stats.tests++;
|
|
103
|
-
|
|
102
|
+
let state = this.stateManager.getCurrentState();
|
|
104
103
|
if (!state) throw new Error('No state found');
|
|
105
104
|
|
|
106
105
|
setActivity(`🧪 Testing: ${task.scenario}`, 'action');
|
|
@@ -122,7 +121,21 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
122
121
|
task.addObservation(`Network error: ${r.method} ${r.path} → ${r.status}`);
|
|
123
122
|
});
|
|
124
123
|
|
|
125
|
-
|
|
124
|
+
let initialState = ActionResult.fromState(state);
|
|
125
|
+
const currentUrl = state.fullUrl || state.url;
|
|
126
|
+
let startOnCurrentPage = opts.startOnCurrentPage;
|
|
127
|
+
if (isErrorPage(initialState) && !startOnCurrentPage && task.startUrl && normalizeUrl(currentUrl) !== normalizeUrl(task.startUrl)) {
|
|
128
|
+
debugLog(`Recovering from error page at ${currentUrl} by navigating to ${task.startUrl}`);
|
|
129
|
+
try {
|
|
130
|
+
await this.explorer.visit(task.startUrl);
|
|
131
|
+
state = this.stateManager.getCurrentState();
|
|
132
|
+
if (!state) throw new Error('No state found after navigating to test start URL');
|
|
133
|
+
initialState = ActionResult.fromState(state);
|
|
134
|
+
startOnCurrentPage = true;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
debugLog(`Could not recover from error page: ${compactErrorMessage(error)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
126
139
|
if (isErrorPage(initialState)) {
|
|
127
140
|
task.start();
|
|
128
141
|
this.testRun = await this.explorer.beginTest(task);
|
|
@@ -153,7 +166,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
153
166
|
expected: task.expected,
|
|
154
167
|
},
|
|
155
168
|
},
|
|
156
|
-
async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts)
|
|
169
|
+
async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, { ...opts, startOnCurrentPage })
|
|
157
170
|
);
|
|
158
171
|
}
|
|
159
172
|
|
|
@@ -481,7 +494,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
481
494
|
<rules>
|
|
482
495
|
Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
|
|
483
496
|
Fall back to interact() when those fail, when the step needs a sequence of actions, or when your context is not enough to locate the element.
|
|
484
|
-
Use tool names exactly as listed in this prompt. Do not invent combined tool names
|
|
497
|
+
Use tool names exactly as listed in this prompt. Do not invent combined tool names or aliases.
|
|
485
498
|
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
486
499
|
Do not do unsuccesful clicks again.
|
|
487
500
|
Do not run same tool calls with same parameters again.
|
|
@@ -517,7 +530,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
517
530
|
|
|
518
531
|
let context = '';
|
|
519
532
|
|
|
520
|
-
const focusArea =
|
|
533
|
+
const focusArea = currentState.overlay;
|
|
521
534
|
|
|
522
535
|
const focusedElement = currentState.focusedElement;
|
|
523
536
|
if (focusedElement) {
|
|
@@ -11,7 +11,7 @@ export class ExitCommand extends BaseCommand {
|
|
|
11
11
|
|
|
12
12
|
async execute(_args: string): Promise<void> {
|
|
13
13
|
await this.explorBot.printSessionAnalysis();
|
|
14
|
-
await this.explorBot.
|
|
14
|
+
await this.explorBot.stop();
|
|
15
15
|
|
|
16
16
|
if (Stats.hasActivity()) {
|
|
17
17
|
await new Promise<void>((resolve) => {
|
|
@@ -63,23 +63,26 @@ export class ExploreCommand extends BaseCommand {
|
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
try {
|
|
67
|
+
if (cfg.enabled) {
|
|
68
|
+
await this.runReuseMode(mainUrl, feature, cfg);
|
|
69
|
+
} else {
|
|
70
|
+
await this.runFreshMode(mainUrl, feature, cfg.styles);
|
|
71
|
+
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
const mainPlan = this.completedPlans[0];
|
|
74
|
+
if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
|
|
75
|
+
if (this.dryRun) {
|
|
76
|
+
this.printResults();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
|
|
80
|
+
const savedPath = this.explorBot.savePlans(this.completedPlans);
|
|
75
81
|
this.printResults();
|
|
76
|
-
|
|
82
|
+
this.printNextSteps(savedPath);
|
|
83
|
+
} finally {
|
|
84
|
+
if (!this.dryRun) await this.explorBot.printSessionAnalysis();
|
|
77
85
|
}
|
|
78
|
-
if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
|
|
79
|
-
const savedPath = this.explorBot.savePlans(this.completedPlans);
|
|
80
|
-
this.printResults();
|
|
81
|
-
await this.explorBot.printSessionAnalysis();
|
|
82
|
-
this.printNextSteps(savedPath);
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
private originLabel(test: Test): string {
|
package/src/config.ts
CHANGED
package/src/explorbot.ts
CHANGED
package/src/state-manager.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ActionResult, type FocusedElement } from './action-result.js';
|
|
2
2
|
import type { ExperienceTracker } from './experience-tracker.js';
|
|
3
3
|
import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
|
|
4
|
-
import { detectFocusArea } from './utils/aria.js';
|
|
5
4
|
import { createDebug, tag } from './utils/logger.js';
|
|
5
|
+
import { Overlay } from './utils/overlay.js';
|
|
6
6
|
import { slugify } from './utils/strings.js';
|
|
7
7
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
8
8
|
|
|
@@ -49,6 +49,7 @@ export interface WebPageState {
|
|
|
49
49
|
focusedElement?: FocusedElement | null;
|
|
50
50
|
links?: Link[];
|
|
51
51
|
verifications?: Record<string, boolean>;
|
|
52
|
+
overlay?: Overlay;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export interface StateTransition {
|
|
@@ -206,8 +207,8 @@ export class StateManager {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
private hasDialogAppeared(previousState: WebPageState | null, newState: WebPageState): boolean {
|
|
209
|
-
const prevFocus =
|
|
210
|
-
const newFocus =
|
|
210
|
+
const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
|
|
211
|
+
const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
|
|
211
212
|
return !prevFocus.detected && newFocus.detected;
|
|
212
213
|
}
|
|
213
214
|
|
package/src/utils/html.ts
CHANGED
|
@@ -98,7 +98,6 @@ export const HTML_SELECTORS = {
|
|
|
98
98
|
interactiveControl: 'button, a[href], input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"]',
|
|
99
99
|
labelLike: 'h1, h2, h3, h4, h5, h6, legend, caption, label, [role="heading"], [class*="title"], [class*="label"], [class*="header"], [class*="name"]',
|
|
100
100
|
semanticContextContainer: 'section, article, form, fieldset, li, tr, td, th, [role="group"], [role="tabpanel"], [role="region"], [class*="card"], [class*="panel"], [class*="item"], [class*="usage"], [class*="group"]',
|
|
101
|
-
semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])'],
|
|
102
101
|
} as const;
|
|
103
102
|
|
|
104
103
|
export const HTML_VISIBILITY_LIMITS = {
|
|
@@ -163,7 +162,9 @@ export type VisibleOverlayExtractionConfig = {
|
|
|
163
162
|
interactiveContentSelector: string;
|
|
164
163
|
limits: typeof HTML_EXTRACTION_LIMITS;
|
|
165
164
|
overlaySelectors: readonly string[];
|
|
165
|
+
overlaySemanticSelector: string;
|
|
166
166
|
visibilityLimits: typeof HTML_VISIBILITY_LIMITS;
|
|
167
|
+
geometryFallback?: boolean;
|
|
167
168
|
};
|
|
168
169
|
export type ComponentScopeExtractionConfig = {
|
|
169
170
|
eidxAttr: string;
|
|
@@ -479,20 +480,28 @@ export function extractVisibleOverlayHtml(config: VisibleOverlayExtractionConfig
|
|
|
479
480
|
return interactiveCount > 0 || text.length > 0;
|
|
480
481
|
}
|
|
481
482
|
|
|
482
|
-
|
|
483
|
+
function isFloatingOverlay(element: Element): boolean {
|
|
484
|
+
const style = window.getComputedStyle(element as HTMLElement);
|
|
485
|
+
return style.position === 'fixed' || style.position === 'absolute' || Number.parseInt(style.zIndex || '0', 10) > 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
483
488
|
const seen = new Set<Element>();
|
|
489
|
+
const collected: Element[] = [];
|
|
484
490
|
for (const selector of config.overlaySelectors) {
|
|
485
491
|
for (const element of Array.from(document.querySelectorAll(selector))) {
|
|
486
492
|
if (seen.has(element)) continue;
|
|
487
493
|
seen.add(element);
|
|
488
494
|
if (!isVisible(element)) continue;
|
|
495
|
+
if (!element.matches(config.overlaySemanticSelector) && !isFloatingOverlay(element)) continue;
|
|
489
496
|
const { interactiveCount, text } = getUsefulContent(element);
|
|
490
497
|
if (interactiveCount === 0 && text.length === 0) continue;
|
|
491
|
-
|
|
498
|
+
collected.push(element);
|
|
492
499
|
}
|
|
493
500
|
}
|
|
494
501
|
|
|
495
|
-
|
|
502
|
+
const overlays = collected.filter((element) => !collected.some((other) => other !== element && element.contains(other))).map((element) => (element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
|
|
503
|
+
|
|
504
|
+
if (overlays.length === 0 && config.geometryFallback !== false) {
|
|
496
505
|
const floatingCandidates = Array.from(document.body.querySelectorAll('*'))
|
|
497
506
|
.filter((element) => !seen.has(element) && isVisible(element) && isLikelyFloatingOverlay(element))
|
|
498
507
|
.sort((left, right) => {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { detectFocusArea } from './aria.js';
|
|
2
|
+
import { HTML_EXTRACTION_LIMITS, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, type VisibleOverlayExtractionConfig, extractHeadings } from './html.js';
|
|
3
|
+
|
|
4
|
+
export const OVERLAY_SELECTORS = {
|
|
5
|
+
semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
|
|
6
|
+
modalOverlays: ['[role="dialog"]', '[role="alertdialog"]', '[aria-modal="true"]', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
|
|
7
|
+
overlaySemanticSelector: '[role="dialog"], [role="alertdialog"], [aria-modal="true"], [role="listbox"], [role="menu"], [role="tooltip"]',
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export type OverlayData = { type?: 'dialog' | 'modal' | null; name?: string | null };
|
|
11
|
+
|
|
12
|
+
export class Overlay {
|
|
13
|
+
readonly type: 'dialog' | 'modal' | null;
|
|
14
|
+
readonly name: string | null;
|
|
15
|
+
|
|
16
|
+
constructor(data: OverlayData = {}) {
|
|
17
|
+
this.type = data.type ?? null;
|
|
18
|
+
this.name = data.name ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get detected(): boolean {
|
|
22
|
+
return this.type !== null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static fromHtml(html: string): Overlay {
|
|
26
|
+
const headings = extractHeadings(html);
|
|
27
|
+
const name = [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean).join(' ');
|
|
28
|
+
return new Overlay({ type: 'modal', name: name || null });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static fromAria(snapshot: string | null): Overlay {
|
|
32
|
+
return new Overlay(detectFocusArea(snapshot));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static resolve(data: { overlayHtml?: string; overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay {
|
|
36
|
+
if (data.overlayHtml) return Overlay.fromHtml(data.overlayHtml);
|
|
37
|
+
if (data.overlay) return new Overlay(data.overlay);
|
|
38
|
+
return Overlay.fromAria(data.ariaSnapshot ?? null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static captureConfig(): VisibleOverlayExtractionConfig {
|
|
42
|
+
return {
|
|
43
|
+
interactiveContentSelector: HTML_SELECTORS.interactiveContent,
|
|
44
|
+
limits: HTML_EXTRACTION_LIMITS,
|
|
45
|
+
overlaySelectors: OVERLAY_SELECTORS.modalOverlays,
|
|
46
|
+
overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
|
|
47
|
+
visibilityLimits: HTML_VISIBILITY_LIMITS,
|
|
48
|
+
geometryFallback: false,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|