explorbot 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +19 -7
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/prima/src/cli.ts +24 -12
- package/boat/prima/src/envelope.ts +35 -13
- package/boat/prima/src/prima.ts +61 -41
- package/dist/bin/explorbot-cli.js +19 -7
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/prima/src/cli.js +19 -7
- package/dist/boat/prima/src/envelope.js +32 -8
- package/dist/boat/prima/src/prima.js +57 -39
- package/dist/package.json +1 -1
- package/dist/src/action.js +5 -1
- package/dist/src/ai/navigator.d.ts +27 -0
- package/dist/src/ai/navigator.js +227 -175
- package/dist/src/ai/pilot.d.ts +7 -4
- package/dist/src/ai/pilot.js +50 -8
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +12 -21
- package/dist/src/ai/researcher/cache.d.ts +2 -0
- package/dist/src/ai/researcher/cache.js +10 -2
- package/dist/src/ai/researcher.js +2 -1
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/tester.d.ts +5 -2
- package/dist/src/ai/tester.js +17 -13
- package/dist/src/ai/tools.js +4 -1
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/config.d.ts +8 -1
- package/dist/src/config.js +40 -0
- package/dist/src/explorbot.js +4 -1
- package/dist/src/remote.d.ts +3 -2
- package/dist/src/remote.js +8 -2
- package/dist/src/state-manager.d.ts +1 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +19 -0
- package/dist/src/utils/logger.d.ts +1 -1
- package/dist/src/utils/logger.js +8 -0
- package/docs/index.json +2 -1
- package/docs/reference/commands.md +3 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/package.json +1 -1
- package/src/action.ts +5 -1
- package/src/ai/navigator.ts +241 -178
- package/src/ai/pilot.ts +63 -12
- package/src/ai/provider.ts +12 -20
- package/src/ai/researcher/cache.ts +12 -2
- package/src/ai/researcher.ts +2 -1
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/tester.ts +20 -12
- package/src/ai/tools.ts +4 -1
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +45 -1
- package/src/explorbot.ts +3 -1
- package/src/remote.ts +8 -2
- package/src/state-manager.ts +5 -2
- package/src/test-plan.ts +20 -0
- package/src/utils/logger.ts +9 -1
package/src/ai/pilot.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { ConfigParser } from '../config.ts';
|
|
|
7
7
|
import type Explorer from '../explorer.ts';
|
|
8
8
|
import type { PlaywrightRecorder } from '../playwright-recorder.ts';
|
|
9
9
|
import type { StateManager } from '../state-manager.ts';
|
|
10
|
+
import { Stats } from '../stats.ts';
|
|
10
11
|
import { type Test, TestResult } from '../test-plan.ts';
|
|
11
12
|
import { collectInteractiveNodes, detectFocusArea } from '../utils/aria.ts';
|
|
12
13
|
import { ErrorPageError } from '../utils/error-page.ts';
|
|
@@ -562,24 +563,46 @@ export class Pilot implements Agent {
|
|
|
562
563
|
return text;
|
|
563
564
|
}
|
|
564
565
|
|
|
565
|
-
async settleExpectations(task: Test): Promise<
|
|
566
|
-
|
|
566
|
+
async settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]> {
|
|
567
|
+
let image: string | null = null;
|
|
568
|
+
if (finalState?.screenshot && this.provider.hasVision()) image = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
|
|
569
|
+
|
|
567
570
|
const decided = (text: string): 'passed' | 'failed' => {
|
|
568
571
|
if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
|
|
569
572
|
return 'failed';
|
|
570
573
|
};
|
|
571
574
|
|
|
575
|
+
let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
|
|
576
|
+
if (image) undecided = task.expected;
|
|
572
577
|
if (!undecided.length) return task.expected.map((text) => ({ text, status: decided(text) }));
|
|
573
578
|
|
|
574
579
|
const schema = z.object({
|
|
575
580
|
outcomes: z.array(
|
|
576
581
|
z.object({
|
|
577
582
|
expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
|
|
578
|
-
status: z.enum(['passed', 'failed', 'unverified']).describe('passed = the
|
|
583
|
+
status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
|
|
584
|
+
evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
|
|
579
585
|
})
|
|
580
586
|
),
|
|
581
587
|
});
|
|
582
588
|
|
|
589
|
+
let pageEvidence = '';
|
|
590
|
+
if (image) {
|
|
591
|
+
pageEvidence = dedent`
|
|
592
|
+
A screenshot of the whole page as the run left it is attached. It is the proof: an outcome is satisfied
|
|
593
|
+
when the page shows it to somebody looking at it. The log only says what the run did.
|
|
594
|
+
|
|
595
|
+
Not finding something in the picture is not by itself a disagreement. Report "contradiction" only when
|
|
596
|
+
the picture shows something incompatible with what the run claims — a list visibly empty, an error where
|
|
597
|
+
a result was expected, the old value still displayed, a control visibly disabled. When you simply cannot
|
|
598
|
+
make it out, say "unverified" and name what you could not find.
|
|
599
|
+
|
|
600
|
+
The picture covers the full page, but not the inside of a region that scrolls on its own, and not the
|
|
601
|
+
state of the page before the run ended. An outcome established earlier stays established even when the
|
|
602
|
+
page has moved past it, and that is not a contradiction.
|
|
603
|
+
`;
|
|
604
|
+
}
|
|
605
|
+
|
|
583
606
|
const userContent = dedent`
|
|
584
607
|
A test run has finished. Decide, for each expected outcome, what the run established about it.
|
|
585
608
|
|
|
@@ -591,23 +614,43 @@ export class Pilot implements Agent {
|
|
|
591
614
|
${task.notesToString() || 'No steps recorded.'}
|
|
592
615
|
</run_log>
|
|
593
616
|
|
|
617
|
+
${pageEvidence}
|
|
618
|
+
|
|
594
619
|
The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
|
|
595
620
|
differently. Judge by what the steps show happened, not by whether the wording matches.
|
|
596
|
-
Choose "unverified" only when the
|
|
621
|
+
Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
|
|
597
622
|
that is a statement about the run, not about the application.
|
|
598
623
|
`;
|
|
599
624
|
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
625
|
+
const settle = (content: any, model: any) =>
|
|
626
|
+
this.provider
|
|
627
|
+
.generateObject([{ role: 'user' as const, content }], schema, model, {
|
|
628
|
+
agentName: 'pilot',
|
|
629
|
+
telemetry: { functionId: 'pilot.settleExpectations' },
|
|
630
|
+
})
|
|
631
|
+
.catch(() => null);
|
|
632
|
+
|
|
633
|
+
let response = null;
|
|
634
|
+
if (image) {
|
|
635
|
+
const seen = [
|
|
636
|
+
{ type: 'text', text: userContent },
|
|
637
|
+
{ type: 'file', mediaType: 'image/png', data: image },
|
|
638
|
+
];
|
|
639
|
+
response = await settle(seen, this.provider.getVisionModel());
|
|
640
|
+
if (!response) {
|
|
641
|
+
Stats.visionDisabled = true;
|
|
642
|
+
tag('warning').log('⚠️ Vision model could not judge the outcomes. Settling them from the run log instead.');
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!response) response = await settle(userContent, this.provider.getAgenticModel('pilot'));
|
|
606
647
|
|
|
607
|
-
const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome
|
|
648
|
+
const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome]));
|
|
608
649
|
return task.expected.map((text) => {
|
|
609
650
|
if (!undecided.includes(text)) return { text, status: decided(text) };
|
|
610
|
-
|
|
651
|
+
const outcome = judged.get(text) as { status: SettledStatus; evidence?: string } | undefined;
|
|
652
|
+
if (!outcome) return { text, status: 'unverified' as SettledStatus };
|
|
653
|
+
return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
|
|
611
654
|
});
|
|
612
655
|
}
|
|
613
656
|
|
|
@@ -1116,3 +1159,11 @@ export class Pilot implements Agent {
|
|
|
1116
1159
|
`;
|
|
1117
1160
|
}
|
|
1118
1161
|
}
|
|
1162
|
+
|
|
1163
|
+
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
1164
|
+
|
|
1165
|
+
export interface SettledExpectation {
|
|
1166
|
+
text: string;
|
|
1167
|
+
status: SettledStatus;
|
|
1168
|
+
evidence?: string;
|
|
1169
|
+
}
|
package/src/ai/provider.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
|
4
4
|
import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
|
|
5
5
|
import type { ModelMessage } from 'ai';
|
|
6
6
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
7
|
-
import type
|
|
7
|
+
import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
|
|
8
8
|
import { executionController } from '../execution-controller.ts';
|
|
9
9
|
import { Observability } from '../observability.ts';
|
|
10
10
|
import { Stats } from '../stats.ts';
|
|
@@ -88,10 +88,6 @@ export class Provider {
|
|
|
88
88
|
this.initLangfuse();
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
private getModelName(model: any): string {
|
|
92
|
-
return model?.modelId || model?.model || 'unknown';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
91
|
async validateConnection(): Promise<void> {
|
|
96
92
|
try {
|
|
97
93
|
await generateText({
|
|
@@ -120,13 +116,13 @@ export class Provider {
|
|
|
120
116
|
return this.config.agenticModel || this.config.model;
|
|
121
117
|
}
|
|
122
118
|
|
|
119
|
+
getVisionModel(): any {
|
|
120
|
+
return this.config.visionModel;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
123
|
getConfiguredModels(): Record<string, string> {
|
|
124
|
-
const models: Record<string, string> = {
|
|
125
|
-
|
|
126
|
-
if (this.config.visionModel) models.visionModel = this.getModelName(this.config.visionModel);
|
|
127
|
-
for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
|
|
128
|
-
if (agentConfig?.model) models[agent] = this.getModelName(agentConfig.model);
|
|
129
|
-
}
|
|
124
|
+
const models: Record<string, string> = {};
|
|
125
|
+
for (const [role, model] of Object.entries(configuredModels(this.config))) models[role] = model.name;
|
|
130
126
|
return models;
|
|
131
127
|
}
|
|
132
128
|
|
|
@@ -223,11 +219,7 @@ export class Provider {
|
|
|
223
219
|
}
|
|
224
220
|
|
|
225
221
|
private initLangfuse() {
|
|
226
|
-
const
|
|
227
|
-
const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
228
|
-
const secretKey = langfuseConfig?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
229
|
-
const baseUrl = langfuseConfig?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST;
|
|
230
|
-
const enabled = langfuseConfig?.enabled ?? Boolean(publicKey && secretKey);
|
|
222
|
+
const { enabled, publicKey, secretKey, baseUrl } = this.config.langfuse || {};
|
|
231
223
|
|
|
232
224
|
if (!enabled || !publicKey || !secretKey) {
|
|
233
225
|
return;
|
|
@@ -316,7 +308,7 @@ export class Provider {
|
|
|
316
308
|
}
|
|
317
309
|
|
|
318
310
|
async chat(messages: ModelMessage[], model: any, options: any = {}): Promise<any> {
|
|
319
|
-
const modelName =
|
|
311
|
+
const modelName = getModelName(model);
|
|
320
312
|
setActivity(`🤖 Asking ${modelName}`, 'ai');
|
|
321
313
|
promptLog(`Using model: ${modelName}`);
|
|
322
314
|
|
|
@@ -360,7 +352,7 @@ export class Provider {
|
|
|
360
352
|
}
|
|
361
353
|
|
|
362
354
|
async generateWithTools(messages: ModelMessage[], model: any, tools: any, options: any = {}): Promise<any> {
|
|
363
|
-
const modelName =
|
|
355
|
+
const modelName = getModelName(model);
|
|
364
356
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
365
357
|
promptLog(`Using model: ${modelName}`);
|
|
366
358
|
|
|
@@ -418,7 +410,7 @@ export class Provider {
|
|
|
418
410
|
|
|
419
411
|
async generateObject(messages: ModelMessage[], schema: any, model?: any, options: any = {}): Promise<any> {
|
|
420
412
|
const modelToUse = model || this.config.model;
|
|
421
|
-
const modelName =
|
|
413
|
+
const modelName = getModelName(modelToUse);
|
|
422
414
|
setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
|
|
423
415
|
promptLog(`Using model: ${modelName}`);
|
|
424
416
|
|
|
@@ -620,7 +612,7 @@ export class Provider {
|
|
|
620
612
|
clearActivity();
|
|
621
613
|
responseLog(response.text);
|
|
622
614
|
|
|
623
|
-
this.recordUsage('vision',
|
|
615
|
+
this.recordUsage('vision', getModelName(this.config.visionModel), response.usage);
|
|
624
616
|
|
|
625
617
|
return response;
|
|
626
618
|
} catch (error: any) {
|
|
@@ -4,6 +4,7 @@ import { Worker } from 'node:worker_threads';
|
|
|
4
4
|
import { outputPath } from '../../config.ts';
|
|
5
5
|
import { TTLCache } from '../../utils/cache.ts';
|
|
6
6
|
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
|
|
7
|
+
import { tag } from '../../utils/logger.ts';
|
|
7
8
|
import { debugLog } from './mixin.ts';
|
|
8
9
|
|
|
9
10
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
@@ -15,6 +16,14 @@ const memoryCache = new TTLCache<string>(CACHE_TTL_MS);
|
|
|
15
16
|
|
|
16
17
|
let fingerprintWorker: Worker | null = null;
|
|
17
18
|
|
|
19
|
+
export function researchPath(hash: string): string {
|
|
20
|
+
return outputPath('research', `${hash}.md`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function reportResearch(hash: string, text: string): void {
|
|
24
|
+
tag('data').log('research', { path: researchPath(hash), hash, content: text });
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
function getStatesDir(): string {
|
|
19
28
|
return outputPath('states');
|
|
20
29
|
}
|
|
@@ -35,7 +44,7 @@ export function getCachedResearch(hash: string): string {
|
|
|
35
44
|
if (!hash) return '';
|
|
36
45
|
const cached = memoryCache.get(hash);
|
|
37
46
|
if (cached !== undefined) return cached;
|
|
38
|
-
const researchFile =
|
|
47
|
+
const researchFile = researchPath(hash);
|
|
39
48
|
if (!existsSync(researchFile)) return '';
|
|
40
49
|
const stats = statSync(researchFile);
|
|
41
50
|
if (Date.now() - stats.mtimeMs > CACHE_TTL_MS) return '';
|
|
@@ -46,7 +55,7 @@ export function getCachedResearch(hash: string): string {
|
|
|
46
55
|
|
|
47
56
|
export function getPreviousResearch(hash: string): string {
|
|
48
57
|
if (!hash) return '';
|
|
49
|
-
const researchFile =
|
|
58
|
+
const researchFile = researchPath(hash);
|
|
50
59
|
if (!existsSync(researchFile)) return '';
|
|
51
60
|
return readFileSync(researchFile, 'utf8');
|
|
52
61
|
}
|
|
@@ -57,6 +66,7 @@ export function saveResearch(hash: string, text: string, combinedHtml?: string):
|
|
|
57
66
|
if (!existsSync(researchDir)) mkdirSync(researchDir, { recursive: true });
|
|
58
67
|
writeFileSync(researchFile, text);
|
|
59
68
|
memoryCache.set(hash, text);
|
|
69
|
+
reportResearch(hash, text);
|
|
60
70
|
debugLog(`Research saved to ${researchFile}`);
|
|
61
71
|
|
|
62
72
|
if (combinedHtml) {
|
package/src/ai/researcher.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { annotatePageElements } from '../utils/web-annotate.ts';
|
|
|
19
19
|
import type { Agent, AgentDeps } from './agent.js';
|
|
20
20
|
import type { Navigator } from './navigator.ts';
|
|
21
21
|
import { ContextLengthError, type Provider } from './provider.js';
|
|
22
|
-
import { findSimilarResearch, getCachedResearch, saveResearch } from './researcher/cache.ts';
|
|
22
|
+
import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from './researcher/cache.ts';
|
|
23
23
|
import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
|
|
24
24
|
import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
|
|
25
25
|
import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
|
|
@@ -102,6 +102,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
102
102
|
const cached = getCachedResearch(stateHash);
|
|
103
103
|
if (cached) {
|
|
104
104
|
debugLog('Previous research result found');
|
|
105
|
+
reportResearch(stateHash, cached);
|
|
105
106
|
return cached;
|
|
106
107
|
}
|
|
107
108
|
}
|
|
@@ -4,6 +4,7 @@ import dedent from 'dedent';
|
|
|
4
4
|
import { outputPath } from '../config.ts';
|
|
5
5
|
import { Stats } from '../stats.ts';
|
|
6
6
|
import type { Test } from '../test-plan.ts';
|
|
7
|
+
import { tag } from '../utils/logger.ts';
|
|
7
8
|
import type { Agent } from './agent.ts';
|
|
8
9
|
import type { Provider } from './provider.ts';
|
|
9
10
|
|
|
@@ -117,6 +118,7 @@ export class SessionAnalyst implements Agent {
|
|
|
117
118
|
const dir = path.dirname(filePath);
|
|
118
119
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
119
120
|
writeFileSync(filePath, markdown);
|
|
121
|
+
tag('data').log('report', { path: filePath, content: markdown });
|
|
120
122
|
return filePath;
|
|
121
123
|
}
|
|
122
124
|
|
package/src/ai/tester.ts
CHANGED
|
@@ -96,7 +96,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
96
96
|
return this.currentConversation;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
async test(task: Test): Promise<{ success: boolean }> {
|
|
99
|
+
async test(task: Test, opts: TestOptions = {}): Promise<{ success: boolean }> {
|
|
100
100
|
Stats.tests++;
|
|
101
101
|
const state = this.stateManager.getCurrentState();
|
|
102
102
|
if (!state) throw new Error('No state found');
|
|
@@ -151,11 +151,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
151
151
|
expected: task.expected,
|
|
152
152
|
},
|
|
153
153
|
},
|
|
154
|
-
async () => this.runTestSession(task, initialState, conversation, { offFailedRequest })
|
|
154
|
+
async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts)
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
private async runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers): Promise<{ success: boolean }> {
|
|
158
|
+
private async runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers, opts: TestOptions): Promise<{ success: boolean }> {
|
|
159
159
|
const { offFailedRequest } = handlers;
|
|
160
160
|
|
|
161
161
|
if (this.pilot) {
|
|
@@ -187,15 +187,19 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
187
187
|
return { success: task.isSuccessful };
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
debugLog(`
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
await this.
|
|
198
|
-
|
|
190
|
+
if (opts.startOnCurrentPage) debugLog(`Starting on the page already open at ${task.startUrl}`);
|
|
191
|
+
|
|
192
|
+
if (!opts.startOnCurrentPage) {
|
|
193
|
+
debugLog(`Navigating to ${task.startUrl}`);
|
|
194
|
+
try {
|
|
195
|
+
await this.explorer.visit(task.startUrl!);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const result = await this.handleLoopError(task, error);
|
|
198
|
+
if (result === 'stop') {
|
|
199
|
+
offFailedRequest?.();
|
|
200
|
+
await this.cleanupStartedTest(task);
|
|
201
|
+
return { success: task.isSuccessful };
|
|
202
|
+
}
|
|
199
203
|
}
|
|
200
204
|
}
|
|
201
205
|
|
|
@@ -1160,3 +1164,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1160
1164
|
interface TestSessionHandlers {
|
|
1161
1165
|
offFailedRequest?: () => void;
|
|
1162
1166
|
}
|
|
1167
|
+
|
|
1168
|
+
export interface TestOptions {
|
|
1169
|
+
startOnCurrentPage?: boolean;
|
|
1170
|
+
}
|
package/src/ai/tools.ts
CHANGED
|
@@ -810,7 +810,10 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
810
810
|
});
|
|
811
811
|
}
|
|
812
812
|
|
|
813
|
-
|
|
813
|
+
let reason = '';
|
|
814
|
+
if (navigator.lastFailureReason) reason = `: ${navigator.lastFailureReason}`;
|
|
815
|
+
|
|
816
|
+
return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
|
|
814
817
|
...toolResult,
|
|
815
818
|
suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
|
|
816
819
|
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, configuredModels } from '../config.js';
|
|
5
|
+
import { listSites } from '../global-config.js';
|
|
6
|
+
import { Reporter } from '../reporter.js';
|
|
7
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
8
|
+
import { tag } from '../utils/logger.js';
|
|
9
|
+
import { BaseCommand } from './base-command.js';
|
|
10
|
+
|
|
11
|
+
export class ConfigCommand extends BaseCommand {
|
|
12
|
+
name = 'config';
|
|
13
|
+
description = 'Show models, config file and paths used by this run';
|
|
14
|
+
|
|
15
|
+
async execute(): Promise<void> {
|
|
16
|
+
const parser = ConfigParser.getInstance();
|
|
17
|
+
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static async summary(options: { config?: string; path?: string; url?: string; json?: boolean } = {}): Promise<string> {
|
|
21
|
+
const parser = ConfigParser.getInstance();
|
|
22
|
+
const [site] = listSites();
|
|
23
|
+
const load = (baseUrl?: string) => parser.loadConfig({ config: options.config, path: options.path, baseUrl });
|
|
24
|
+
|
|
25
|
+
const config = await load(options.url).catch((error) => {
|
|
26
|
+
if (!site) throw error;
|
|
27
|
+
return load(site.url);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static data(config: SummarizedConfig, options: ConfigSummaryOptions = {}): ConfigData {
|
|
34
|
+
let configPath = '';
|
|
35
|
+
if (options.configPath && existsSync(options.configPath)) configPath = options.configPath;
|
|
36
|
+
|
|
37
|
+
const dirs: Record<string, string> = {};
|
|
38
|
+
if (options.root) {
|
|
39
|
+
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
40
|
+
dirs[name] = path.join(options.root, dir);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const env: Record<string, string> = {};
|
|
45
|
+
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
46
|
+
const value = process.env[variable.name];
|
|
47
|
+
if (value) env[variable.name] = value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const models: Record<string, string> = {};
|
|
51
|
+
const providers: Record<string, string> = {};
|
|
52
|
+
for (const [role, model] of Object.entries(configuredModels(config.ai))) {
|
|
53
|
+
models[role] = model.name;
|
|
54
|
+
if (model.provider) providers[role] = model.provider;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
config: configPath,
|
|
59
|
+
url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
|
|
60
|
+
browser: config.playwright?.browser || '',
|
|
61
|
+
headless: !config.playwright?.show,
|
|
62
|
+
dirs,
|
|
63
|
+
models,
|
|
64
|
+
providers,
|
|
65
|
+
integrations: {
|
|
66
|
+
langfuse: !!config.ai?.langfuse?.enabled,
|
|
67
|
+
testomatio: Reporter.resolveEnabled(config.reporter),
|
|
68
|
+
},
|
|
69
|
+
env,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static render(config: SummarizedConfig, options: ConfigSummaryOptions = {}): string {
|
|
74
|
+
const data = ConfigCommand.data(config, options);
|
|
75
|
+
if (options.json) return JSON.stringify(data, null, 2);
|
|
76
|
+
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
const section = (title: string, entries: [string, string][]) => {
|
|
79
|
+
if (!entries.length) return;
|
|
80
|
+
const width = Math.max(...entries.map(([label]) => label.length));
|
|
81
|
+
lines.push(chalk.bold(title));
|
|
82
|
+
for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
|
|
83
|
+
lines.push('');
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
87
|
+
if (data.url) general.push(['url', data.url]);
|
|
88
|
+
if (data.browser) {
|
|
89
|
+
let window = 'visible';
|
|
90
|
+
if (data.headless) window = 'headless';
|
|
91
|
+
general.push(['browser', `${data.browser}, ${window}`]);
|
|
92
|
+
}
|
|
93
|
+
for (const [name, dir] of Object.entries(data.dirs)) general.push([name, dir]);
|
|
94
|
+
section('Config', general);
|
|
95
|
+
|
|
96
|
+
const providerWidth = Math.max(0, ...Object.values(data.providers).map((provider) => provider.length));
|
|
97
|
+
const models: [string, string][] = Object.entries(data.models).map(([role, model]) => {
|
|
98
|
+
if (!providerWidth) return [role, model];
|
|
99
|
+
return [role, `${chalk.dim((data.providers[role] || '').padEnd(providerWidth))} ${model}`];
|
|
100
|
+
});
|
|
101
|
+
if (!models.length) models.push(['model', chalk.red(`not configured — run ${getCliName()} init`)]);
|
|
102
|
+
section('Models', models);
|
|
103
|
+
|
|
104
|
+
const integrations: [string, string][] = [];
|
|
105
|
+
if (data.integrations.langfuse) integrations.push(['langfuse', 'traces sent']);
|
|
106
|
+
if (data.integrations.testomatio) integrations.push(['testomatio', 'runs reported']);
|
|
107
|
+
section('Integrations', integrations);
|
|
108
|
+
|
|
109
|
+
const env: [string, string][] = Object.entries(data.env).map(([name, value]) => {
|
|
110
|
+
let shown = value;
|
|
111
|
+
if (shown.length > 60) shown = `${shown.slice(0, 57)}...`;
|
|
112
|
+
return [name, shown];
|
|
113
|
+
});
|
|
114
|
+
section('Environment', env);
|
|
115
|
+
|
|
116
|
+
lines.push(chalk.dim(`Every EXPLORBOT_* variable: ${getCliName()} --help`));
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ConfigSummaryOptions {
|
|
122
|
+
configPath?: string | null;
|
|
123
|
+
root?: string;
|
|
124
|
+
json?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ConfigData {
|
|
128
|
+
config: string;
|
|
129
|
+
url: string;
|
|
130
|
+
browser: string;
|
|
131
|
+
headless: boolean;
|
|
132
|
+
dirs: Record<string, string>;
|
|
133
|
+
models: Record<string, string>;
|
|
134
|
+
providers: Record<string, string>;
|
|
135
|
+
integrations: { langfuse: boolean; testomatio: boolean };
|
|
136
|
+
env: Record<string, string>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface SummarizedConfig {
|
|
140
|
+
ai?: AIConfig;
|
|
141
|
+
playwright?: { url?: string; browser?: string; show?: boolean };
|
|
142
|
+
web?: { url?: string };
|
|
143
|
+
api?: { baseEndpoint?: string };
|
|
144
|
+
dirs?: Record<string, string>;
|
|
145
|
+
reporter?: ReporterConfig;
|
|
146
|
+
}
|
package/src/commands/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AddRuleCommand } from './add-rule-command.js';
|
|
|
3
3
|
import type { BaseCommand } from './base-command.js';
|
|
4
4
|
import { CleanCommand } from './clean-command.js';
|
|
5
5
|
import { CompactCommand } from './compact-command.js';
|
|
6
|
+
import { ConfigCommand } from './config-command.js';
|
|
6
7
|
import { ContextAriaCommand } from './context-aria-command.js';
|
|
7
8
|
import { ContextCommand } from './context-command.js';
|
|
8
9
|
import { ContextDataCommand } from './context-data-command.js';
|
|
@@ -70,6 +71,7 @@ const commandClasses: CommandClass[] = [
|
|
|
70
71
|
RunsCommand,
|
|
71
72
|
RerunCommand,
|
|
72
73
|
StatusCommand,
|
|
74
|
+
ConfigCommand,
|
|
73
75
|
DebugCommand,
|
|
74
76
|
ExitCommand,
|
|
75
77
|
];
|
package/src/config.ts
CHANGED
|
@@ -514,6 +514,7 @@ export class ConfigParser {
|
|
|
514
514
|
model: { modelId: 'test-model', provider: 'test' },
|
|
515
515
|
config: {},
|
|
516
516
|
vision: false,
|
|
517
|
+
langfuse: { enabled: false },
|
|
517
518
|
},
|
|
518
519
|
dirs: {
|
|
519
520
|
knowledge: join(testBaseDir, 'knowledge'),
|
|
@@ -650,6 +651,18 @@ export class ConfigParser {
|
|
|
650
651
|
config.playwright.url = options.baseUrl;
|
|
651
652
|
}
|
|
652
653
|
|
|
654
|
+
if (config.ai) {
|
|
655
|
+
const langfuse = config.ai.langfuse;
|
|
656
|
+
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
657
|
+
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
658
|
+
config.ai.langfuse = {
|
|
659
|
+
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
660
|
+
publicKey,
|
|
661
|
+
secretKey,
|
|
662
|
+
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
653
666
|
return config;
|
|
654
667
|
}
|
|
655
668
|
|
|
@@ -762,6 +775,32 @@ export function missingConfigMessage(configFile = 'explorbot.config.js'): string
|
|
|
762
775
|
`;
|
|
763
776
|
}
|
|
764
777
|
|
|
778
|
+
export function modelName(model: unknown): string {
|
|
779
|
+
if (typeof model === 'string') return model;
|
|
780
|
+
return (model as any)?.modelId || (model as any)?.model || 'unknown';
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function modelProvider(model: unknown): string {
|
|
784
|
+
const provider = (model as any)?.provider;
|
|
785
|
+
if (typeof provider === 'string') return provider.split('.')[0];
|
|
786
|
+
if (typeof model === 'string') return model.split('/')[0];
|
|
787
|
+
return '';
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel> {
|
|
791
|
+
if (!ai?.model) return {};
|
|
792
|
+
|
|
793
|
+
const describe = (model: unknown): ConfiguredModel => ({ name: modelName(model), provider: modelProvider(model) });
|
|
794
|
+
|
|
795
|
+
const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
|
|
796
|
+
if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
|
|
797
|
+
if (ai.visionModel) models.visionModel = describe(ai.visionModel);
|
|
798
|
+
for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
|
|
799
|
+
if (agentConfig?.model) models[agent] = describe(agentConfig.model);
|
|
800
|
+
}
|
|
801
|
+
return models;
|
|
802
|
+
}
|
|
803
|
+
|
|
765
804
|
export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
766
805
|
if (!ai) return;
|
|
767
806
|
|
|
@@ -838,6 +877,11 @@ export async function createModel(provider: string, modelId: string): Promise<an
|
|
|
838
877
|
|
|
839
878
|
type ModelRole = 'model' | 'visionModel' | 'agenticModel';
|
|
840
879
|
|
|
880
|
+
interface ConfiguredModel {
|
|
881
|
+
name: string;
|
|
882
|
+
provider: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
841
885
|
interface ProviderInfo {
|
|
842
886
|
envKey: string;
|
|
843
887
|
load: () => Promise<(modelId: string) => any>;
|
|
@@ -849,4 +893,4 @@ interface EnvVar {
|
|
|
849
893
|
required?: boolean;
|
|
850
894
|
}
|
|
851
895
|
|
|
852
|
-
export type { ModelRole, EnvVar, ProviderInfo };
|
|
896
|
+
export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
|
package/src/explorbot.ts
CHANGED
|
@@ -487,9 +487,11 @@ export class ExplorBot {
|
|
|
487
487
|
|
|
488
488
|
setCurrentPlan(plan?: Plan): void {
|
|
489
489
|
this.currentPlan = plan;
|
|
490
|
-
if (
|
|
490
|
+
if (!plan) return;
|
|
491
|
+
if (!this.sessionPlans.includes(plan)) {
|
|
491
492
|
this.sessionPlans.push(plan);
|
|
492
493
|
}
|
|
494
|
+
plan.notifyChange();
|
|
493
495
|
}
|
|
494
496
|
|
|
495
497
|
getSessionTests(): Test[] {
|
package/src/remote.ts
CHANGED
|
@@ -16,8 +16,9 @@ const FLUSH_TIMEOUT_MS = 3000;
|
|
|
16
16
|
* process and a CI bot are the same case.
|
|
17
17
|
*
|
|
18
18
|
* It **is** a LogDestination — that is the whole integration on the logger's
|
|
19
|
-
* side — and it answers asks by installing itself
|
|
20
|
-
* controller's input callback. Nothing else in explorbot
|
|
19
|
+
* side, messages and `data` alike — and it answers asks by installing itself
|
|
20
|
+
* as the execution controller's input callback. Nothing else in explorbot
|
|
21
|
+
* knows it exists.
|
|
21
22
|
*/
|
|
22
23
|
export class Remote implements LogDestination {
|
|
23
24
|
private url: string | null = null;
|
|
@@ -110,6 +111,11 @@ export class Remote implements LogDestination {
|
|
|
110
111
|
*/
|
|
111
112
|
write(entry: TaggedLogEntry): void {
|
|
112
113
|
if (entry.type === 'html') return;
|
|
114
|
+
if (entry.type === 'data') {
|
|
115
|
+
const [kind, payload] = entry.originalArgs || [];
|
|
116
|
+
this.send(String(kind), payload);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
113
119
|
let content = stripAnsi(entry.content ?? '');
|
|
114
120
|
if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
|
|
115
121
|
this.send('log', {
|
package/src/state-manager.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type FocusedElement
|
|
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
4
|
import { detectFocusArea } from './utils/aria.js';
|
|
5
|
-
import { createDebug } from './utils/logger.js';
|
|
5
|
+
import { createDebug, tag } from './utils/logger.js';
|
|
6
6
|
import { slugify } from './utils/strings.js';
|
|
7
7
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
8
8
|
|
|
@@ -117,6 +117,9 @@ export class StateManager {
|
|
|
117
117
|
* Emit state change event to all listeners
|
|
118
118
|
*/
|
|
119
119
|
private emitStateChange(event: StateTransition): void {
|
|
120
|
+
const state = event.toState;
|
|
121
|
+
tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
|
|
122
|
+
|
|
120
123
|
this.stateChangeListeners.forEach((listener) => {
|
|
121
124
|
try {
|
|
122
125
|
listener(event);
|