explorbot 0.1.27 → 0.1.29
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/README.md +83 -245
- package/bin/explorbot-cli.ts +1 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +2 -0
- package/dist/src/action.js +88 -7
- package/dist/src/ai/captain/file-tools.js +100 -0
- package/dist/src/ai/captain/idle-mode.js +70 -6
- package/dist/src/ai/captain/web-mode.js +36 -6
- package/dist/src/ai/captain.js +87 -19
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/historian/screencast.js +11 -2
- package/dist/src/ai/navigator.js +5 -2
- package/dist/src/ai/pilot.js +52 -9
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/researcher/coordinates.js +2 -3
- package/dist/src/ai/researcher/deep-analysis.js +3 -4
- package/dist/src/ai/researcher/locators.js +1 -2
- package/dist/src/ai/researcher.js +24 -19
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/task-agent.js +1 -0
- package/dist/src/ai/tester.js +161 -46
- package/dist/src/ai/tools.js +84 -8
- package/dist/src/commands/explore-command.js +6 -1
- package/dist/src/components/LogPane.js +4 -3
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +270 -35
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/dist/src/utils/browser-errors.js +23 -0
- package/dist/src/utils/error-page.js +17 -2
- package/dist/src/utils/logger.js +2 -2
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +2 -0
- package/src/action.ts +83 -7
- package/src/ai/captain/file-tools.ts +126 -0
- package/src/ai/captain/idle-mode.ts +72 -6
- package/src/ai/captain/mixin.ts +1 -1
- package/src/ai/captain/web-mode.ts +40 -5
- package/src/ai/captain.ts +94 -20
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/historian/screencast.ts +11 -2
- package/src/ai/navigator.ts +6 -2
- package/src/ai/pilot.ts +53 -9
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/researcher/coordinates.ts +2 -3
- package/src/ai/researcher/deep-analysis.ts +3 -4
- package/src/ai/researcher/locators.ts +1 -2
- package/src/ai/researcher.ts +25 -19
- package/src/ai/rules.ts +46 -0
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +175 -48
- package/src/ai/tools.ts +97 -8
- package/src/commands/explore-command.ts +6 -1
- package/src/components/LogPane.tsx +4 -3
- package/src/config.ts +1 -0
- package/src/explorbot.ts +6 -2
- package/src/explorer.ts +295 -38
- package/src/state-manager.ts +2 -0
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
- package/src/utils/browser-errors.ts +25 -0
- package/src/utils/error-page.ts +16 -3
- package/src/utils/logger.ts +3 -3
package/dist/src/action.js
CHANGED
|
@@ -13,8 +13,8 @@ import { Observability } from "./observability.js";
|
|
|
13
13
|
import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
14
14
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
15
15
|
import { safeFilename } from "./utils/strings.js";
|
|
16
|
+
import { isFatalBrowserError } from "./utils/browser-errors.js";
|
|
16
17
|
const debugLog = createDebug('explorbot:action');
|
|
17
|
-
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
18
18
|
class Action {
|
|
19
19
|
actor;
|
|
20
20
|
stateManager;
|
|
@@ -28,6 +28,7 @@ class Action {
|
|
|
28
28
|
playwrightGroupId = null;
|
|
29
29
|
assertionSteps = [];
|
|
30
30
|
recorder;
|
|
31
|
+
mainDocumentStatus = undefined;
|
|
31
32
|
constructor(actor, stateManager, recorder) {
|
|
32
33
|
this.actor = actor;
|
|
33
34
|
this.stateManager = stateManager;
|
|
@@ -63,20 +64,25 @@ class Action {
|
|
|
63
64
|
const page = this.playwrightHelper.page;
|
|
64
65
|
const frame = this.playwrightHelper.frame;
|
|
65
66
|
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
|
|
66
|
-
|
|
67
|
+
await waitForUsablePageDom(page);
|
|
68
|
+
const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
|
|
67
69
|
const [html, title, browserLogs] = await grabAll().catch(async (err) => {
|
|
68
70
|
const msg = err instanceof Error ? err.message : String(err);
|
|
69
71
|
if (!/navigating and changing the content/i.test(msg))
|
|
70
72
|
throw err;
|
|
71
73
|
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
|
|
74
|
+
await waitForUsablePageDom(page);
|
|
72
75
|
return grabAll();
|
|
73
76
|
});
|
|
74
77
|
const url = page?.url() || (await this.actor.grabCurrentUrl?.());
|
|
75
78
|
let screenshotFile = undefined;
|
|
79
|
+
const statesDir = outputPath('states');
|
|
80
|
+
fs.mkdirSync(statesDir, { recursive: true });
|
|
76
81
|
if (includeScreenshot) {
|
|
77
82
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
const screenshotPath = join(statesDir, filename);
|
|
84
|
+
screenshotFile = await page
|
|
85
|
+
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
80
86
|
.then(() => filename)
|
|
81
87
|
.catch((err) => {
|
|
82
88
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
@@ -84,8 +90,6 @@ class Action {
|
|
|
84
90
|
});
|
|
85
91
|
}
|
|
86
92
|
// Save HTML to file
|
|
87
|
-
const statesDir = outputPath('states');
|
|
88
|
-
fs.mkdirSync(statesDir, { recursive: true });
|
|
89
93
|
const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
|
|
90
94
|
const htmlPath = join(statesDir, htmlFile);
|
|
91
95
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
@@ -121,6 +125,7 @@ class Action {
|
|
|
121
125
|
const result = new ActionResult({
|
|
122
126
|
html,
|
|
123
127
|
title,
|
|
128
|
+
httpStatus: await this.captureMainDocumentStatus(),
|
|
124
129
|
url,
|
|
125
130
|
browserLogs,
|
|
126
131
|
htmlFile,
|
|
@@ -136,13 +141,57 @@ class Action {
|
|
|
136
141
|
}
|
|
137
142
|
catch (err) {
|
|
138
143
|
const msg = err instanceof Error ? err.message : String(err);
|
|
139
|
-
if (
|
|
144
|
+
if (isFatalBrowserError(err))
|
|
140
145
|
throw err;
|
|
141
146
|
debugLog('capturePageState failed with non-fatal error:', msg);
|
|
142
147
|
const url = this.playwrightHelper.page?.url?.() || '';
|
|
143
148
|
return new ActionResult({ url, error: msg });
|
|
144
149
|
}
|
|
145
150
|
}
|
|
151
|
+
async captureMainDocumentStatus() {
|
|
152
|
+
if (this.mainDocumentStatus)
|
|
153
|
+
return this.mainDocumentStatus;
|
|
154
|
+
try {
|
|
155
|
+
const page = this.playwrightHelper.page;
|
|
156
|
+
const status = await page.evaluate(() => {
|
|
157
|
+
const navigation = performance.getEntriesByType('navigation').at(-1);
|
|
158
|
+
if (!navigation)
|
|
159
|
+
return undefined;
|
|
160
|
+
if (new URL(navigation.name).href !== window.location.href)
|
|
161
|
+
return undefined;
|
|
162
|
+
return navigation.responseStatus;
|
|
163
|
+
});
|
|
164
|
+
if (typeof status !== 'number')
|
|
165
|
+
return undefined;
|
|
166
|
+
if (status <= 0)
|
|
167
|
+
return undefined;
|
|
168
|
+
return status;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
captureMainDocumentResponse() {
|
|
175
|
+
const page = this.playwrightHelper.page;
|
|
176
|
+
if (!page?.on || !page?.off)
|
|
177
|
+
return () => { };
|
|
178
|
+
this.mainDocumentStatus = undefined;
|
|
179
|
+
const handler = (response) => {
|
|
180
|
+
const request = response.request();
|
|
181
|
+
if (request.resourceType() !== 'document')
|
|
182
|
+
return;
|
|
183
|
+
if (response.frame() !== page.mainFrame())
|
|
184
|
+
return;
|
|
185
|
+
const status = response.status();
|
|
186
|
+
if (typeof status !== 'number')
|
|
187
|
+
return;
|
|
188
|
+
if (status <= 0)
|
|
189
|
+
return;
|
|
190
|
+
this.mainDocumentStatus = status;
|
|
191
|
+
};
|
|
192
|
+
page.on('response', handler);
|
|
193
|
+
return () => page.off('response', handler);
|
|
194
|
+
}
|
|
146
195
|
/**
|
|
147
196
|
* Capture HTML snapshots of all iframes on the page
|
|
148
197
|
*/
|
|
@@ -200,6 +249,7 @@ class Action {
|
|
|
200
249
|
const stepListener = attachStepLogger(executedSteps, assertionSteps);
|
|
201
250
|
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
202
251
|
this.playwrightGroupId = groupId;
|
|
252
|
+
const detachMainDocumentResponse = this.captureMainDocumentResponse();
|
|
203
253
|
const activeSpan = Observability.getSpan();
|
|
204
254
|
const tracer = trace.getTracer('ai');
|
|
205
255
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
@@ -242,6 +292,7 @@ class Action {
|
|
|
242
292
|
throw err;
|
|
243
293
|
}
|
|
244
294
|
finally {
|
|
295
|
+
detachMainDocumentResponse();
|
|
245
296
|
if (groupId)
|
|
246
297
|
await this.recorder.endAction();
|
|
247
298
|
detachStepLogger(stepListener);
|
|
@@ -330,6 +381,8 @@ class Action {
|
|
|
330
381
|
}
|
|
331
382
|
catch (error) {
|
|
332
383
|
this.lastError = error;
|
|
384
|
+
if (isFatalBrowserError(error))
|
|
385
|
+
throw error;
|
|
333
386
|
debugLog(`Attempt failed: ${codeBlock}: ${errorToString(error) || this.lastError?.toString()}`);
|
|
334
387
|
return false;
|
|
335
388
|
}
|
|
@@ -354,6 +407,34 @@ function errorToString(error) {
|
|
|
354
407
|
}
|
|
355
408
|
return error.message || error.toString();
|
|
356
409
|
}
|
|
410
|
+
async function waitForUsablePageDom(page) {
|
|
411
|
+
if (!page?.waitForFunction)
|
|
412
|
+
return;
|
|
413
|
+
await page
|
|
414
|
+
.waitForFunction(() => {
|
|
415
|
+
const body = document.body;
|
|
416
|
+
if (!body)
|
|
417
|
+
return false;
|
|
418
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
419
|
+
}, undefined, { timeout: 5000 })
|
|
420
|
+
.catch(() => { });
|
|
421
|
+
}
|
|
422
|
+
async function captureHtml(page, frame, actor) {
|
|
423
|
+
if (frame?.content)
|
|
424
|
+
return frame.content();
|
|
425
|
+
if (page?.content)
|
|
426
|
+
return page.content();
|
|
427
|
+
if (actor?.grabSource)
|
|
428
|
+
return actor.grabSource();
|
|
429
|
+
throw new Error('Playwright page is unavailable for HTML capture');
|
|
430
|
+
}
|
|
431
|
+
async function captureTitle(page, actor) {
|
|
432
|
+
if (page?.title)
|
|
433
|
+
return page.title();
|
|
434
|
+
if (actor?.grabTitle)
|
|
435
|
+
return actor.grabTitle();
|
|
436
|
+
return '';
|
|
437
|
+
}
|
|
357
438
|
function sanitizeCodeBlock(code) {
|
|
358
439
|
return code
|
|
359
440
|
.split('\n')
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
export const CAPTAIN_ARTIFACT_DIRS = ['reports', 'plans', 'tests', 'states'];
|
|
4
|
+
export const CAPTAIN_ALLOWED_READ_DIRS = ['output', 'knowledge', 'experience'];
|
|
5
|
+
export const CAPTAIN_ARTIFACT_SCAN_LIMIT = 200;
|
|
6
|
+
export const CAPTAIN_ARTIFACT_LIST_LIMIT = 20;
|
|
7
|
+
export const CAPTAIN_READ_FILE_DEFAULT_LIMIT = 12000;
|
|
8
|
+
export const CAPTAIN_READ_FILE_MAX_LIMIT = 50000;
|
|
9
|
+
export const CAPTAIN_READ_FILE_MIN_LIMIT = 1000;
|
|
10
|
+
export function listRecentArtifacts(outputDir) {
|
|
11
|
+
const artifacts = [];
|
|
12
|
+
for (const dir of CAPTAIN_ARTIFACT_DIRS) {
|
|
13
|
+
if (artifacts.length >= CAPTAIN_ARTIFACT_SCAN_LIMIT)
|
|
14
|
+
break;
|
|
15
|
+
const targetDir = join(outputDir, dir);
|
|
16
|
+
if (!existsSync(targetDir))
|
|
17
|
+
continue;
|
|
18
|
+
collectArtifacts(outputDir, targetDir, artifacts);
|
|
19
|
+
}
|
|
20
|
+
return artifacts
|
|
21
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
22
|
+
.slice(0, CAPTAIN_ARTIFACT_LIST_LIMIT)
|
|
23
|
+
.map(({ timestamp, ...artifact }) => artifact);
|
|
24
|
+
}
|
|
25
|
+
export function readCaptainFile(projectRoot, input, allowedDirs = CAPTAIN_ALLOWED_READ_DIRS) {
|
|
26
|
+
const resolved = resolveReadableFile(projectRoot, input.path, allowedDirs);
|
|
27
|
+
if (!resolved) {
|
|
28
|
+
return { success: false, message: 'File is outside allowed directories' };
|
|
29
|
+
}
|
|
30
|
+
if (!existsSync(resolved)) {
|
|
31
|
+
return { success: false, message: `File not found: ${input.path}` };
|
|
32
|
+
}
|
|
33
|
+
if (!statSync(resolved).isFile()) {
|
|
34
|
+
return { success: false, message: `Not a file: ${input.path}` };
|
|
35
|
+
}
|
|
36
|
+
const maxChars = normalizeMaxChars(input.maxChars);
|
|
37
|
+
const fullContent = readFileSync(resolved, 'utf8');
|
|
38
|
+
const content = selectContent(fullContent, input);
|
|
39
|
+
return {
|
|
40
|
+
success: true,
|
|
41
|
+
path: relative(projectRoot || process.cwd(), resolved),
|
|
42
|
+
truncated: content.length > maxChars,
|
|
43
|
+
content: content.slice(0, maxChars),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function collectArtifacts(outputDir, targetDir, artifacts) {
|
|
47
|
+
for (const entry of readdirSync(targetDir, { withFileTypes: true })) {
|
|
48
|
+
if (artifacts.length >= CAPTAIN_ARTIFACT_SCAN_LIMIT)
|
|
49
|
+
return;
|
|
50
|
+
const entryPath = join(targetDir, entry.name);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
collectArtifacts(outputDir, entryPath, artifacts);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const stats = statSync(entryPath);
|
|
56
|
+
artifacts.push({
|
|
57
|
+
path: relative(outputDir, entryPath),
|
|
58
|
+
size: stats.size,
|
|
59
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
60
|
+
timestamp: stats.mtimeMs,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function resolveReadableFile(projectRoot, requestedPath, allowedDirs) {
|
|
65
|
+
if (!projectRoot)
|
|
66
|
+
return null;
|
|
67
|
+
let cleanPath = requestedPath.trim();
|
|
68
|
+
const projectName = basename(projectRoot);
|
|
69
|
+
if (cleanPath.startsWith(`${projectName}/`) || cleanPath.startsWith(`${projectName}\\`)) {
|
|
70
|
+
cleanPath = cleanPath.slice(projectName.length + 1);
|
|
71
|
+
}
|
|
72
|
+
const resolved = isAbsolute(cleanPath) ? resolve(cleanPath) : resolve(projectRoot, cleanPath);
|
|
73
|
+
const allowedRoots = allowedDirs.map((dir) => resolve(projectRoot, dir));
|
|
74
|
+
for (const root of allowedRoots) {
|
|
75
|
+
const rel = relative(root, resolved);
|
|
76
|
+
if (!rel || (!rel.startsWith('..') && !isAbsolute(rel)))
|
|
77
|
+
return resolved;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function selectContent(content, input) {
|
|
82
|
+
if (!input.startLine && !input.endLine)
|
|
83
|
+
return content;
|
|
84
|
+
const lines = content.split(/\r?\n/);
|
|
85
|
+
const startIndex = resolveLineIndex(input.startLine, lines.length, 1);
|
|
86
|
+
const endIndex = resolveLineIndex(input.endLine, lines.length, lines.length);
|
|
87
|
+
if (endIndex < startIndex)
|
|
88
|
+
return '';
|
|
89
|
+
return lines.slice(startIndex - 1, endIndex).join('\n');
|
|
90
|
+
}
|
|
91
|
+
function resolveLineIndex(line, totalLines, fallback) {
|
|
92
|
+
if (!line)
|
|
93
|
+
return fallback;
|
|
94
|
+
if (line < 0)
|
|
95
|
+
return Math.max(1, totalLines + line + 1);
|
|
96
|
+
return Math.min(Math.max(1, line), totalLines);
|
|
97
|
+
}
|
|
98
|
+
function normalizeMaxChars(maxChars) {
|
|
99
|
+
return Math.max(CAPTAIN_READ_FILE_MIN_LIMIT, Math.min(maxChars || CAPTAIN_READ_FILE_DEFAULT_LIMIT, CAPTAIN_READ_FILE_MAX_LIMIT));
|
|
100
|
+
}
|
|
@@ -4,6 +4,7 @@ import dedent from 'dedent';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { ConfigParser } from "../../config.js";
|
|
6
6
|
import { Test } from "../../test-plan.js";
|
|
7
|
+
import { listRecentArtifacts, readCaptainFile } from "./file-tools.js";
|
|
7
8
|
import { resolveProjectRoot } from "./mixin.js";
|
|
8
9
|
let cachedBashTool = null;
|
|
9
10
|
export function WithIdleMode(Base) {
|
|
@@ -13,6 +14,8 @@ export function WithIdleMode(Base) {
|
|
|
13
14
|
const config = ConfigParser.getInstance().getConfig();
|
|
14
15
|
const knowledgeDir = config.dirs?.knowledge || 'knowledge';
|
|
15
16
|
const experienceDir = config.dirs?.experience || 'experience';
|
|
17
|
+
const outputDir = config.dirs?.output || 'output';
|
|
18
|
+
const readableDirs = [outputDir, knowledgeDir, experienceDir];
|
|
16
19
|
if (!cachedBashTool && projectRoot) {
|
|
17
20
|
cachedBashTool = await createBashTool({
|
|
18
21
|
destination: projectRoot,
|
|
@@ -69,6 +72,53 @@ export function WithIdleMode(Base) {
|
|
|
69
72
|
return { success: true, tests: plan.tests.length };
|
|
70
73
|
},
|
|
71
74
|
}),
|
|
75
|
+
project: tool({
|
|
76
|
+
description: dedent `
|
|
77
|
+
Inspect Explorbot project configuration and recent generated artifacts.
|
|
78
|
+
Use this before answering questions about setup, previous sessions, reports, saved plans, or output files.
|
|
79
|
+
`,
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
view: z.enum(['config', 'artifacts']).optional().describe('config shows setup summary; artifacts lists recent generated files'),
|
|
82
|
+
}),
|
|
83
|
+
execute: async ({ view }) => {
|
|
84
|
+
const parser = ConfigParser.getInstance();
|
|
85
|
+
const config = parser.getConfig();
|
|
86
|
+
const outputDir = parser.getOutputDir();
|
|
87
|
+
if (view === 'artifacts') {
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
outputDir,
|
|
91
|
+
artifacts: listRecentArtifacts(outputDir),
|
|
92
|
+
suggestion: 'Use readFile to inspect specific reports, plans, logs, generated tests, knowledge, or experience files.',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
configPath: parser.getConfigPath(),
|
|
98
|
+
baseUrl: config.playwright?.url,
|
|
99
|
+
browser: config.playwright?.browser,
|
|
100
|
+
headed: config.playwright?.show === true,
|
|
101
|
+
dirs: config.dirs,
|
|
102
|
+
agents: Object.fromEntries(Object.entries(config.ai?.agents || {}).map(([name, agentConfig]) => [name, { enabled: agentConfig?.enabled !== false, hasModelOverride: !!agentConfig?.model }])),
|
|
103
|
+
reporterEnabled: config.reporter?.enabled === true,
|
|
104
|
+
apiEnabled: !!config.api,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
readFile: tool({
|
|
109
|
+
description: dedent `
|
|
110
|
+
Read a specific Explorbot project file for analysis.
|
|
111
|
+
Use this for explicit user questions about reports, plans, logs, generated tests, knowledge, or experience files.
|
|
112
|
+
Prefer this over bash() for reading file contents after bash has found the file.
|
|
113
|
+
`,
|
|
114
|
+
inputSchema: z.object({
|
|
115
|
+
path: z.string().describe('Path inside output, knowledge, or experience directories'),
|
|
116
|
+
startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
|
|
117
|
+
endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
|
|
118
|
+
maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
|
|
119
|
+
}),
|
|
120
|
+
execute: async (input) => readCaptainFile(projectRoot, input, readableDirs),
|
|
121
|
+
}),
|
|
72
122
|
};
|
|
73
123
|
if (cachedBashTool) {
|
|
74
124
|
tools.bash = cachedBashTool.bash;
|
|
@@ -79,17 +129,31 @@ export function WithIdleMode(Base) {
|
|
|
79
129
|
const config = ConfigParser.getInstance().getConfig();
|
|
80
130
|
const knowledgeDir = config.dirs?.knowledge || 'knowledge';
|
|
81
131
|
const experienceDir = config.dirs?.experience || 'experience';
|
|
132
|
+
const outputDir = config.dirs?.output || 'output';
|
|
82
133
|
return dedent `
|
|
83
134
|
<idle_capabilities>
|
|
84
135
|
- Plan management: updatePlan() — replace or append tests in the current plan
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
- Use
|
|
90
|
-
- Use
|
|
136
|
+
- readFile() — read specific report, plan, log, generated test, knowledge, or experience file content
|
|
137
|
+
- bash() — discover files and inspect file metadata
|
|
138
|
+
- READ from: ${knowledgeDir}/, ${experienceDir}/, ${outputDir}/
|
|
139
|
+
- WRITE to: ${knowledgeDir}/, ${experienceDir}/ only (NOT ${outputDir}/)
|
|
140
|
+
- Use wc -l -c file.txt to inspect size
|
|
141
|
+
- Use file file.txt to inspect type
|
|
142
|
+
- Use find . -name "*.md" to discover files
|
|
143
|
+
- Use grep -n "keyword" file.txt to find matching lines
|
|
144
|
+
- Use ls -lh to list files
|
|
91
145
|
</idle_capabilities>
|
|
92
146
|
|
|
147
|
+
<file_reading>
|
|
148
|
+
Use bash() for file discovery and search. Once the needed file and line range are known,
|
|
149
|
+
use readFile() to read its contents. Do not use bash() to print file contents.
|
|
150
|
+
</file_reading>
|
|
151
|
+
|
|
152
|
+
<project_inspection>
|
|
153
|
+
Use project({ view: "config" }) before explaining Explorbot setup or suggesting config improvements.
|
|
154
|
+
Use project({ view: "artifacts" }) before answering questions about previous sessions, reports, plans, generated tests, or logs.
|
|
155
|
+
</project_inspection>
|
|
156
|
+
|
|
93
157
|
<knowledge_saving>
|
|
94
158
|
When user shares credentials, selectors, or important domain info during conversation,
|
|
95
159
|
suggest saving it to a knowledge file using bash tool.
|
|
@@ -45,19 +45,49 @@ export function WithWebMode(Base) {
|
|
|
45
45
|
description: dedent `
|
|
46
46
|
Direct browser access via Playwright. Use for diagnostics and browser management.
|
|
47
47
|
Actions:
|
|
48
|
+
- status: Inspect browser/page availability, URL, title, tab count
|
|
48
49
|
- evaluate: Run JavaScript in browser context (localStorage, cookies, DOM, console)
|
|
49
50
|
- closeTabs: Close all browser tabs except the current one
|
|
50
|
-
- screenshot: Take a screenshot of current page
|
|
51
51
|
- reload: Reload the current page
|
|
52
|
+
- screenshot: Take a screenshot of current page
|
|
53
|
+
- recover: Recover from a closed/crashed page using Explorer recovery
|
|
54
|
+
- restart: Restart the browser when page/context recovery is not enough
|
|
55
|
+
- openFreshTab: Open a fresh tab in the current browser context
|
|
52
56
|
`,
|
|
53
57
|
inputSchema: z.object({
|
|
54
|
-
action: z.enum(['evaluate', 'closeTabs', 'screenshot', '
|
|
58
|
+
action: z.enum(['status', 'evaluate', 'closeTabs', 'reload', 'screenshot', 'recover', 'restart', 'openFreshTab']).describe('Browser action to perform'),
|
|
55
59
|
code: z.string().optional().describe('JavaScript code for evaluate action'),
|
|
56
60
|
}),
|
|
57
61
|
execute: async ({ action, code }) => {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
62
|
+
const explorer = ctx.explorBot.getExplorer();
|
|
63
|
+
if (action === 'status') {
|
|
64
|
+
const page = explorer.playwrightHelper?.page;
|
|
65
|
+
const pages = page?.context?.().pages?.() || [];
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
hasPage: !!page,
|
|
69
|
+
isClosed: page?.isClosed?.() || false,
|
|
70
|
+
url: page && !page.isClosed?.() ? await page.url() : null,
|
|
71
|
+
title: page && !page.isClosed?.() ? await page.title().catch(() => null) : null,
|
|
72
|
+
tabs: pages.length,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (action === 'recover') {
|
|
76
|
+
const recovered = await explorer.recoverFromBrowserError();
|
|
77
|
+
return { success: recovered, message: recovered ? 'Browser page recovered' : 'Browser recovery failed' };
|
|
78
|
+
}
|
|
79
|
+
if (action === 'restart') {
|
|
80
|
+
const restarted = await explorer.restartBrowser();
|
|
81
|
+
return { success: restarted, message: restarted ? 'Browser restarted' : 'Browser restart failed' };
|
|
82
|
+
}
|
|
83
|
+
if (action === 'openFreshTab') {
|
|
84
|
+
await ctx.explorBot.openFreshTab();
|
|
85
|
+
const state = explorer.getStateManager().getCurrentState();
|
|
86
|
+
return { success: true, url: state?.url, title: state?.title };
|
|
87
|
+
}
|
|
88
|
+
const page = explorer.playwrightHelper?.page;
|
|
89
|
+
if (!page || page.isClosed?.())
|
|
90
|
+
return { success: false, message: 'No browser page available. Try browser({ action: "recover" }) first.' };
|
|
61
91
|
if (action === 'evaluate') {
|
|
62
92
|
if (!code)
|
|
63
93
|
return { success: false, message: 'Code required for evaluate action' };
|
|
@@ -106,7 +136,7 @@ export function WithWebMode(Base) {
|
|
|
106
136
|
<web_capabilities>
|
|
107
137
|
- Page actions: click, pressKey, form (CodeceptJS tools)
|
|
108
138
|
- Navigation: navigate() — AI-powered navigation to URLs or page descriptions
|
|
109
|
-
- Browser diagnostics: browser() — evaluate JS, close tabs, screenshot, reload
|
|
139
|
+
- Browser diagnostics: browser() — inspect status, evaluate JS, close tabs, screenshot, reload, recover closed/crashed pages, restart browser, open a fresh tab
|
|
110
140
|
- Visual analysis: see() — screenshot-based page verification
|
|
111
141
|
- Context refresh: context() — get fresh HTML/ARIA snapshot
|
|
112
142
|
- Visual fallback: visualClick() — coordinate-based click when locators fail
|
package/dist/src/ai/captain.js
CHANGED
|
@@ -18,7 +18,6 @@ import { TaskAgent } from "./task-agent.js";
|
|
|
18
18
|
const MAX_STEPS = 15;
|
|
19
19
|
const CaptainBase = WithTestMode(WithWebMode(WithIdleMode(TaskAgent)));
|
|
20
20
|
export class Captain extends CaptainBase {
|
|
21
|
-
ACTION_TOOLS = ['click', 'pressKey', 'form', 'navigate'];
|
|
22
21
|
emoji = '🧑✈️';
|
|
23
22
|
explorBot;
|
|
24
23
|
conversation = null;
|
|
@@ -56,6 +55,12 @@ export class Captain extends CaptainBase {
|
|
|
56
55
|
}
|
|
57
56
|
trackToolExecutions(toolExecutions) {
|
|
58
57
|
super.trackToolExecutions(toolExecutions);
|
|
58
|
+
if (toolExecutions.length > 0) {
|
|
59
|
+
this.recentToolCalls.push(...toolExecutions);
|
|
60
|
+
if (this.recentToolCalls.length > 20) {
|
|
61
|
+
this.recentToolCalls = this.recentToolCalls.slice(-20);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
59
64
|
for (const exec of toolExecutions) {
|
|
60
65
|
const label = toolExecutionLabel(exec.input);
|
|
61
66
|
if (!label)
|
|
@@ -64,15 +69,20 @@ export class Captain extends CaptainBase {
|
|
|
64
69
|
tag('substep').log(`${icon} ${label}`);
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
getMode() {
|
|
73
|
+
const explorer = this.explorBot.getExplorer();
|
|
74
|
+
const activeTest = explorer.activeTest;
|
|
75
|
+
const page = explorer.playwrightHelper?.page;
|
|
76
|
+
if (activeTest && (!page || page.isClosed?.()))
|
|
77
|
+
return 'heal';
|
|
78
|
+
if (activeTest)
|
|
69
79
|
return 'test';
|
|
70
|
-
if (
|
|
80
|
+
if (explorer.getStateManager().getCurrentState())
|
|
71
81
|
return 'web';
|
|
72
82
|
return 'idle';
|
|
73
83
|
}
|
|
74
84
|
systemPrompt() {
|
|
75
|
-
const mode = this.
|
|
85
|
+
const mode = this.getMode();
|
|
76
86
|
const currentUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
|
|
77
87
|
const customPrompt = this.explorBot.getProvider().getSystemPromptForAgent('captain', currentUrl);
|
|
78
88
|
return dedent `
|
|
@@ -85,18 +95,21 @@ export class Captain extends CaptainBase {
|
|
|
85
95
|
- idle: plan management, file operations, knowledge. Always available.
|
|
86
96
|
- web: page interaction, navigation, browser diagnostics. When working with a web page.
|
|
87
97
|
- test: test analysis, state inspection. When a test is running or analyzing results.
|
|
98
|
+
- heal: browser/test recovery. When a test is running and browser state is broken or unavailable.
|
|
88
99
|
</modes>
|
|
89
100
|
|
|
90
101
|
${this.idleModePrompt()}
|
|
91
|
-
${mode === 'web' ? this.webModePrompt() : ''}
|
|
92
|
-
${mode === 'test' ? this.testModePrompt() : ''}
|
|
102
|
+
${mode === 'web' || mode === 'heal' ? this.webModePrompt() : ''}
|
|
103
|
+
${mode === 'test' || mode === 'heal' ? this.testModePrompt() : ''}
|
|
93
104
|
|
|
94
105
|
<rules>
|
|
95
106
|
- After a successful action, if the pageDiff confirms the goal, call done() immediately — do not verify with see() or context() unless the user explicitly asked for verification
|
|
96
107
|
- Prefer completing in fewer tool calls over thoroughness
|
|
97
108
|
- NEVER run tests unless the user explicitly asks
|
|
98
|
-
|
|
99
|
-
${mode === '
|
|
109
|
+
- If you are answering with information rather than completing a browser action, include the actual user-facing answer in done({ details }). Do not only say that it was shown or explained.
|
|
110
|
+
${mode === 'web' || mode === 'heal' ? this.webModeRules() : ''}
|
|
111
|
+
${mode === 'test' || mode === 'heal' ? this.testModeRules() : ''}
|
|
112
|
+
${mode === 'heal' ? '- First diagnose browser availability, then recover the browser/page before continuing test analysis.' : ''}
|
|
100
113
|
</rules>
|
|
101
114
|
|
|
102
115
|
${customPrompt || ''}
|
|
@@ -228,9 +241,20 @@ export class Captain extends CaptainBase {
|
|
|
228
241
|
description: 'Call when the user request is fulfilled.',
|
|
229
242
|
inputSchema: z.object({
|
|
230
243
|
summary: z.string().describe('What was done'),
|
|
244
|
+
details: z.string().optional().describe('Actual user-facing content. Required when the user asked to show, display, explain, summarize, compare, or diagnose information.'),
|
|
231
245
|
}),
|
|
232
|
-
execute: async ({ summary }) => {
|
|
246
|
+
execute: async ({ summary, details }) => {
|
|
233
247
|
debugLog('done', summary);
|
|
248
|
+
if (!details?.trim() && !this.canCompleteWithoutDetails()) {
|
|
249
|
+
return {
|
|
250
|
+
success: false,
|
|
251
|
+
message: 'No user-facing result was provided. Call done() again with the actual answer in details, or complete a browser action first.',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (details?.trim()) {
|
|
255
|
+
tag('details').log(details);
|
|
256
|
+
task.addNote(details);
|
|
257
|
+
}
|
|
234
258
|
task.addNote(summary);
|
|
235
259
|
onDone(summary);
|
|
236
260
|
return { success: true, summary };
|
|
@@ -239,6 +263,9 @@ export class Captain extends CaptainBase {
|
|
|
239
263
|
runCommand: tool({
|
|
240
264
|
description: dedent `
|
|
241
265
|
Execute a TUI command. Returns log output from command execution.
|
|
266
|
+
Use only when the user explicitly asks to run a slash command.
|
|
267
|
+
Never use this to analyze files, reports, logs, plans, generated tests, knowledge, or experience.
|
|
268
|
+
Never run a slash command unless the user request itself starts with that slash command.
|
|
242
269
|
${this.commandDescriptions
|
|
243
270
|
.map((c) => {
|
|
244
271
|
const opts = c.options ? ` (${c.options})` : '';
|
|
@@ -253,6 +280,13 @@ export class Captain extends CaptainBase {
|
|
|
253
280
|
if (!this.commandExecutor)
|
|
254
281
|
return { success: false, message: 'Command executor not available' };
|
|
255
282
|
const cmd = command.startsWith('/') ? command : `/${command}`;
|
|
283
|
+
if (!isExplicitSlashRequest(task.description, cmd)) {
|
|
284
|
+
return {
|
|
285
|
+
success: false,
|
|
286
|
+
command: cmd,
|
|
287
|
+
message: 'Command blocked: slash commands require an explicit matching slash-command request from the user.',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
256
290
|
startLogCapture();
|
|
257
291
|
try {
|
|
258
292
|
await this.commandExecutor(cmd);
|
|
@@ -265,10 +299,12 @@ export class Captain extends CaptainBase {
|
|
|
265
299
|
};
|
|
266
300
|
}
|
|
267
301
|
async tools(task, onDone) {
|
|
268
|
-
const mode = this.
|
|
302
|
+
const mode = this.getMode();
|
|
269
303
|
const ctx = { explorBot: this.explorBot, task };
|
|
270
304
|
const core = this.coreTools(task, onDone);
|
|
271
305
|
const idle = await this.idleModeTools(ctx);
|
|
306
|
+
if (mode === 'heal')
|
|
307
|
+
return { ...core, ...idle, ...this.testModeTools(ctx), ...this.webModeTools(ctx) };
|
|
272
308
|
if (mode === 'test')
|
|
273
309
|
return { ...core, ...idle, ...this.testModeTools(ctx) };
|
|
274
310
|
if (mode === 'web')
|
|
@@ -333,17 +369,24 @@ export class Captain extends CaptainBase {
|
|
|
333
369
|
}
|
|
334
370
|
return result.object;
|
|
335
371
|
}
|
|
372
|
+
async processExecutionError(error, activeTest) {
|
|
373
|
+
const explorer = this.explorBot.getExplorer();
|
|
374
|
+
const result = await explorer.handleExecutionError(error);
|
|
375
|
+
return {
|
|
376
|
+
...result,
|
|
377
|
+
message: result.recovered ? `${result.message}\nContinue the test "${activeTest.scenario}" from the restored page.` : result.message,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
canCompleteWithoutDetails() {
|
|
381
|
+
return (this.recentToolCalls || []).some(hasBrowserCompletionEvidence);
|
|
382
|
+
}
|
|
336
383
|
async handle(input, options = {}) {
|
|
337
384
|
const stateManager = this.explorBot.getExplorer().getStateManager();
|
|
338
385
|
const initialState = stateManager.getCurrentState();
|
|
339
|
-
if (!initialState) {
|
|
340
|
-
tag('warning').log('No page loaded. Use /navigate or I.amOnPage() first.');
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
386
|
const conversation = options.reset ? this.resetConversation() : this.ensureConversation();
|
|
344
387
|
let isDone = false;
|
|
345
388
|
let finalSummary = null;
|
|
346
|
-
const startUrl = initialState
|
|
389
|
+
const startUrl = initialState?.url || '';
|
|
347
390
|
const task = new Task(input, startUrl);
|
|
348
391
|
const onDone = (summary) => {
|
|
349
392
|
isDone = true;
|
|
@@ -376,11 +419,13 @@ export class Captain extends CaptainBase {
|
|
|
376
419
|
return;
|
|
377
420
|
}
|
|
378
421
|
const currentState = stateManager.getCurrentState();
|
|
379
|
-
if (!currentState) {
|
|
422
|
+
if (!currentState && this.getMode() !== 'idle') {
|
|
380
423
|
stop();
|
|
381
424
|
return;
|
|
382
425
|
}
|
|
383
|
-
|
|
426
|
+
if (currentState) {
|
|
427
|
+
await this.reinjectContextIfNeeded(conversation, currentState);
|
|
428
|
+
}
|
|
384
429
|
if (userInput) {
|
|
385
430
|
const newContext = await this.getPageContext();
|
|
386
431
|
conversation.addUserText(dedent `
|
|
@@ -410,7 +455,7 @@ export class Captain extends CaptainBase {
|
|
|
410
455
|
}
|
|
411
456
|
if (result?.toolExecutions?.length) {
|
|
412
457
|
const lastExec = result.toolExecutions[result.toolExecutions.length - 1];
|
|
413
|
-
if (
|
|
458
|
+
if (hasBrowserCompletionEvidence(lastExec)) {
|
|
414
459
|
conversation.addUserText('Action succeeded. If the goal is achieved, call done() now with a brief summary.');
|
|
415
460
|
}
|
|
416
461
|
}
|
|
@@ -437,3 +482,26 @@ export class Captain extends CaptainBase {
|
|
|
437
482
|
}
|
|
438
483
|
}
|
|
439
484
|
export default Captain;
|
|
485
|
+
function isExplicitSlashRequest(input, command) {
|
|
486
|
+
const requested = slashCommandToken(input);
|
|
487
|
+
const actual = slashCommandToken(command);
|
|
488
|
+
if (!requested || !actual)
|
|
489
|
+
return false;
|
|
490
|
+
return requested === actual;
|
|
491
|
+
}
|
|
492
|
+
function slashCommandToken(value) {
|
|
493
|
+
const trimmed = value.trim();
|
|
494
|
+
if (!trimmed.startsWith('/'))
|
|
495
|
+
return null;
|
|
496
|
+
for (let i = 1; i < trimmed.length; i++) {
|
|
497
|
+
if (trimmed[i] <= ' ')
|
|
498
|
+
return trimmed.slice(0, i);
|
|
499
|
+
}
|
|
500
|
+
return trimmed;
|
|
501
|
+
}
|
|
502
|
+
function hasBrowserCompletionEvidence(execution) {
|
|
503
|
+
if (!execution?.wasSuccessful)
|
|
504
|
+
return false;
|
|
505
|
+
const output = execution.output || {};
|
|
506
|
+
return Boolean(output.pageDiff || output.code || output.playwrightGroupId);
|
|
507
|
+
}
|