explorbot 0.4.3 → 0.4.4
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/boat/api-tester/src/apibot.ts +8 -13
- package/boat/api-tester/src/cli.ts +7 -3
- package/boat/api-tester/src/config.ts +45 -9
- package/boat/prima/src/cli.ts +33 -99
- package/boat/prima/src/envelope.ts +3 -1
- package/boat/prima/src/help.ts +72 -0
- package/boat/prima/src/prima.ts +33 -43
- package/dist/boat/api-tester/src/apibot.js +7 -6
- package/dist/boat/api-tester/src/cli.js +9 -3
- package/dist/boat/api-tester/src/config.js +32 -6
- package/dist/boat/prima/src/cli.js +30 -86
- package/dist/boat/prima/src/envelope.js +2 -1
- package/dist/boat/prima/src/help.js +63 -0
- package/dist/boat/prima/src/prima.js +29 -41
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +5 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +4 -1
- package/dist/src/ai/researcher/sections.d.ts +1 -1
- package/dist/src/ai/researcher/sections.js +2 -1
- package/dist/src/ai/researcher.js +25 -11
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +5 -0
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +14 -0
- package/dist/src/state-manager.js +5 -1
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +1 -0
- package/docs/workflow/agentic-usage.md +3 -1
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/researcher/deep-analysis.ts +4 -2
- package/src/ai/researcher/sections.ts +2 -2
- package/src/ai/researcher.ts +28 -11
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +6 -0
- package/src/commands/config-command.ts +4 -1
- package/src/config.ts +16 -0
- package/src/state-manager.ts +6 -1
package/dist/src/config.js
CHANGED
|
@@ -27,6 +27,7 @@ export const PROVIDERS = {
|
|
|
27
27
|
};
|
|
28
28
|
export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
|
|
29
29
|
let cachedOutputRoot = null;
|
|
30
|
+
let runOutputDir = null;
|
|
30
31
|
const config = {
|
|
31
32
|
playwright: {
|
|
32
33
|
browser: 'chromium',
|
|
@@ -49,6 +50,7 @@ export const EXPLORBOT_ENV_VARS = [
|
|
|
49
50
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
50
51
|
{ name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
|
|
51
52
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
53
|
+
{ name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
|
|
52
54
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
53
55
|
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
54
56
|
];
|
|
@@ -213,6 +215,7 @@ export class ConfigParser {
|
|
|
213
215
|
// For testing purposes only
|
|
214
216
|
static resetForTesting() {
|
|
215
217
|
cachedOutputRoot = null;
|
|
218
|
+
runOutputDir = null;
|
|
216
219
|
if (ConfigParser.instance) {
|
|
217
220
|
ConfigParser.instance.config = null;
|
|
218
221
|
ConfigParser.instance.configPath = null;
|
|
@@ -441,9 +444,20 @@ export class ConfigParser {
|
|
|
441
444
|
}
|
|
442
445
|
}
|
|
443
446
|
}
|
|
447
|
+
export function setOutputDir(dir) {
|
|
448
|
+
runOutputDir = dir;
|
|
449
|
+
}
|
|
444
450
|
export function outputPath(...segments) {
|
|
451
|
+
if (runOutputDir)
|
|
452
|
+
return path.join(runOutputDir, ...segments);
|
|
445
453
|
return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
|
|
446
454
|
}
|
|
455
|
+
export function agentSettings(config, agent) {
|
|
456
|
+
const ai = (config.ai ??= { model: null });
|
|
457
|
+
const agents = (ai.agents ??= {});
|
|
458
|
+
agents[agent] ??= {};
|
|
459
|
+
return agents[agent];
|
|
460
|
+
}
|
|
447
461
|
export async function resolveModel(spec, role = 'model') {
|
|
448
462
|
const separator = spec.indexOf('/');
|
|
449
463
|
if (separator > 0) {
|
|
@@ -60,12 +60,16 @@ export class StateManager {
|
|
|
60
60
|
updateState(actionResult, codeBlock, trigger = 'manual') {
|
|
61
61
|
const previousState = this.currentState;
|
|
62
62
|
const previousHash = previousState?.hash;
|
|
63
|
+
const hashChanged = actionResult.hash !== previousHash;
|
|
64
|
+
if (!hashChanged && previousState?.verifications) {
|
|
65
|
+
const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
|
|
66
|
+
actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
|
|
67
|
+
}
|
|
63
68
|
const newState = actionResult;
|
|
64
69
|
this.currentState = newState;
|
|
65
70
|
this.currentState.id = this.nextStateId++;
|
|
66
71
|
if (newState.url)
|
|
67
72
|
this.allVisitedUrls.add(normalizeUrl(newState.url));
|
|
68
|
-
const hashChanged = actionResult.hash !== previousHash;
|
|
69
73
|
const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
|
|
70
74
|
if (hashChanged || regionOpened) {
|
|
71
75
|
const transition = {
|
|
@@ -30,7 +30,7 @@ export default {
|
|
|
30
30
|
|
|
31
31
|
- **`baseEndpoint`** (required) — the base URL prepended to every request. Test steps use relative paths like `/users`; Curler adds the base for you.
|
|
32
32
|
- **`spec`** (required) — one or more OpenAPI specs, given as HTTP(S) URLs or local file paths, in YAML or JSON. Chief uses the spec to plan; Curler uses it to look up schemas. Both agents refuse to run without one.
|
|
33
|
-
- **`headers`** — sent with every request. This is where API keys and auth tokens go.
|
|
33
|
+
- **`headers`** — sent with every request. This is where API keys and auth tokens go. `-H "Name: value"` on the command line and `EXPLORBOT_API_HEADERS` add to them without a config file.
|
|
34
34
|
|
|
35
35
|
See the [full configuration reference](../reference/configuration.md) for every option and [providers](../basics/providers.md) for choosing an AI model.
|
|
36
36
|
|
|
@@ -60,16 +60,24 @@ A matching `teardown` hook runs after all tests finish — use it to clean up da
|
|
|
60
60
|
|
|
61
61
|
Chief and Curler need three things: where the API is, what its spec says, and how to authenticate. Pass all three on the command line and no config file is needed:
|
|
62
62
|
|
|
63
|
+
```bash
|
|
64
|
+
npx explorbot api explore https://api.example.com/v1 \
|
|
65
|
+
--spec ./openapi.yaml \
|
|
66
|
+
-H "Authorization: Bearer $TOKEN"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`api explore` takes the base endpoint as its argument, so one line covers the whole run: it plans in every style, executes each plan, and reports the totals. The other commands take a path within the API and read the base from `--endpoint`:
|
|
70
|
+
|
|
63
71
|
```bash
|
|
64
72
|
npx explorbot api plan /users \
|
|
65
73
|
--endpoint https://api.example.com/v1 \
|
|
66
74
|
--spec ./openapi.yaml \
|
|
67
|
-
|
|
75
|
+
-H "Authorization: Bearer $TOKEN"
|
|
68
76
|
```
|
|
69
77
|
|
|
70
|
-
|
|
78
|
+
Each flag has an environment twin — `EXPLORBOT_URL`, `EXPLORBOT_API_SPEC` and `EXPLORBOT_API_HEADERS` — and the flag wins when both are set. `-H` is repeatable and takes one `Name: value` per use; the variable takes one per line. Headers land on every request, the startup health check included, and merge over any `headers` a config file sets. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
The base endpoint keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads the base from the flag or the variable.
|
|
73
81
|
|
|
74
82
|
### A dedicated API project
|
|
75
83
|
|
|
@@ -118,6 +118,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
|
|
|
118
118
|
| `EXPLORBOT_KNOWLEDGE_FILE` | Path to a knowledge markdown file |
|
|
119
119
|
| `EXPLORBOT_SPEC` | Docbot application spec directory or index.md, used as page knowledge |
|
|
120
120
|
| `EXPLORBOT_API_SPEC` | OpenAPI spec path for the API boat |
|
|
121
|
+
| `EXPLORBOT_API_HEADERS` | Headers sent with every API request, one "Name: value" per line |
|
|
121
122
|
| `EXPLORBOT_NO_BANNER` | Suppress the startup banner, for machine-readable output |
|
|
122
123
|
| `EXPLORBOT_MAX_DURATION` | Wall-clock budget in minutes for an explore run; same as --max-duration |
|
|
123
124
|
<!-- END env -->
|
|
@@ -58,6 +58,7 @@ No `init`, no config file, no project directory, no model IDs to look up. These
|
|
|
58
58
|
| `EXPLORBOT_KNOWLEDGE_FILE` | no | Path to a knowledge markdown file |
|
|
59
59
|
| `EXPLORBOT_SPEC` | no | Docbot application spec directory or index.md, used as page knowledge |
|
|
60
60
|
| `EXPLORBOT_API_SPEC` | no | OpenAPI spec path for the API boat |
|
|
61
|
+
| `EXPLORBOT_API_HEADERS` | no | Headers sent with every API request, one "Name: value" per line |
|
|
61
62
|
| `EXPLORBOT_NO_BANNER` | no | Suppress the startup banner, for machine-readable output |
|
|
62
63
|
| `EXPLORBOT_MAX_DURATION` | no | Wall-clock budget in minutes for an explore run; same as --max-duration |
|
|
63
64
|
<!-- END env -->
|
|
@@ -231,11 +232,12 @@ The same variables drive API testing and doc collection.
|
|
|
231
232
|
```bash
|
|
232
233
|
EXPLORBOT_URL=https://api.example.com \
|
|
233
234
|
EXPLORBOT_API_SPEC=./openapi.yaml \
|
|
235
|
+
EXPLORBOT_API_HEADERS="Authorization: Bearer $TOKEN" \
|
|
234
236
|
EXPLORBOT_AI_PROVIDER=openrouter \
|
|
235
237
|
npx explorbot api explore /users
|
|
236
238
|
```
|
|
237
239
|
|
|
238
|
-
The API boat also takes those
|
|
240
|
+
The API boat also takes those three as flags, so one line carries the whole run: `npx explorbot api explore https://api.example.com --spec ./openapi.yaml -H "Authorization: Bearer $TOKEN"`. Given a full URL, `api explore` reads it as the base endpoint; a path like `/users` needs the base in `--endpoint` or `EXPLORBOT_URL`.
|
|
239
241
|
|
|
240
242
|
```bash
|
|
241
243
|
EXPLORBOT_AI_PROVIDER=openrouter \
|
package/package.json
CHANGED
package/src/action-result.ts
CHANGED
|
@@ -33,6 +33,7 @@ interface ActionResultData extends WebPageState {
|
|
|
33
33
|
iframeSnapshots?: Array<{ src: string; html: string; id?: string }>;
|
|
34
34
|
ariaSnapshot?: string | null;
|
|
35
35
|
ariaSnapshotFile?: string;
|
|
36
|
+
regionAria?: string | null;
|
|
36
37
|
focusedElement?: FocusedElement | null;
|
|
37
38
|
iframeURL?: string;
|
|
38
39
|
links?: Link[];
|
|
@@ -90,6 +91,7 @@ export class ActionResult implements ActionResultData {
|
|
|
90
91
|
public links: Link[] = [];
|
|
91
92
|
public verifications?: Record<string, boolean>;
|
|
92
93
|
public overlay: Region = new Region();
|
|
94
|
+
public regionAria: string | null = null;
|
|
93
95
|
private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
|
|
94
96
|
|
|
95
97
|
constructor(data: ActionResultData) {
|
|
@@ -106,6 +108,7 @@ export class ActionResult implements ActionResultData {
|
|
|
106
108
|
this.iframeURL = data.iframeURL;
|
|
107
109
|
this.notes = data.notes ?? [];
|
|
108
110
|
this.verifications = data.verifications;
|
|
111
|
+
this.regionAria = data.regionAria ?? null;
|
|
109
112
|
|
|
110
113
|
// Set readonly properties
|
|
111
114
|
if (data.screenshotFile !== undefined) {
|
|
@@ -304,6 +307,10 @@ export class ActionResult implements ActionResultData {
|
|
|
304
307
|
return compactAriaSnapshot(this.ariaSnapshot, false);
|
|
305
308
|
}
|
|
306
309
|
|
|
310
|
+
getRegionARIA(): string {
|
|
311
|
+
return compactAriaSnapshot(this.regionAria, false);
|
|
312
|
+
}
|
|
313
|
+
|
|
307
314
|
getCompactARIA(): string {
|
|
308
315
|
return compactAriaSnapshot(this.ariaSnapshot, true);
|
|
309
316
|
}
|
package/src/action.ts
CHANGED
|
@@ -14,8 +14,8 @@ import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError }
|
|
|
14
14
|
import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
15
15
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
16
16
|
import { Overlay, OverlayPage } from './utils/overlay.js';
|
|
17
|
-
import type { Region } from './utils/region.js';
|
|
18
17
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
18
|
+
import type { Region } from './utils/region.js';
|
|
19
19
|
import { safeFilename } from './utils/strings.ts';
|
|
20
20
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
|
|
21
21
|
|
|
@@ -182,7 +182,19 @@ class Action {
|
|
|
182
182
|
focusedElement,
|
|
183
183
|
iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
|
|
184
184
|
});
|
|
185
|
-
if (!frame)
|
|
185
|
+
if (!frame) {
|
|
186
|
+
await this.detectRegion(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
|
|
187
|
+
const regionRoot = result.overlay.root;
|
|
188
|
+
if (result.overlay.isModal && regionRoot) {
|
|
189
|
+
result.regionAria = await this.playwrightHelper.page
|
|
190
|
+
.locator(regionRoot)
|
|
191
|
+
.ariaSnapshot()
|
|
192
|
+
.catch((err: Error) => {
|
|
193
|
+
debugLog('Region ARIA snapshot failed:', err.message);
|
|
194
|
+
return null;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
186
198
|
this.stateManager.updateState(result, codeBlock);
|
|
187
199
|
return result;
|
|
188
200
|
} catch (err) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { ActionResult, type Diff } from '../../action-result.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type ExplorbotConfig, agentSettings } from '../../config.ts';
|
|
4
4
|
import { executionController } from '../../execution-controller.ts';
|
|
5
5
|
import type Explorer from '../../explorer.ts';
|
|
6
6
|
import type { StateManager } from '../../state-manager.js';
|
|
@@ -31,7 +31,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
31
31
|
tag('info').log('Starting deep analysis of expandable elements');
|
|
32
32
|
await (this as any).navigateTo(state.fullUrl || state.url);
|
|
33
33
|
|
|
34
|
-
const maxClicks = (this.config
|
|
34
|
+
const maxClicks = agentSettings(this.config, 'researcher').maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
|
|
35
35
|
|
|
36
36
|
const expandedSections: string[] = [];
|
|
37
37
|
const navigationLinks: Array<{ code: string; url: string }> = [];
|
|
@@ -88,6 +88,8 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
91
|
+
if (!(this as any).isEnabled()) return null;
|
|
92
|
+
|
|
91
93
|
const region = current.overlay;
|
|
92
94
|
if (!region.isOpen || !region.name) return null;
|
|
93
95
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import type { ActionResult } from '../../action-result.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type ExplorbotConfig, agentSettings } from '../../config.ts';
|
|
4
4
|
import { executionController } from '../../execution-controller.ts';
|
|
5
5
|
import type Explorer from '../../explorer.ts';
|
|
6
6
|
import type { StateManager } from '../../state-manager.js';
|
|
@@ -66,7 +66,7 @@ export function WithSections<T extends Constructor>(Base: T) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
private async _detectFocusCss(): Promise<string | null> {
|
|
69
|
-
const focusSections = (this.config
|
|
69
|
+
const focusSections = agentSettings(this.config, 'researcher').focusSections;
|
|
70
70
|
if (!focusSections?.length) return null;
|
|
71
71
|
|
|
72
72
|
for (const css of focusSections) {
|
package/src/ai/researcher.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { ActionResult } from '../action-result.js';
|
|
3
3
|
import { setActivity } from '../activity.ts';
|
|
4
|
-
import { ConfigParser, type ExplorbotConfig, outputPath } from '../config.ts';
|
|
4
|
+
import { ConfigParser, type ExplorbotConfig, type ResearcherAgentConfig, agentSettings, outputPath } from '../config.ts';
|
|
5
5
|
import { executionController } from '../execution-controller.ts';
|
|
6
6
|
import type { ExperienceTracker } from '../experience-tracker.ts';
|
|
7
7
|
import type Explorer from '../explorer.ts';
|
|
@@ -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, reportResearch, saveResearch } from './researcher/cache.ts';
|
|
22
|
+
import { findSimilarResearch, getCachedResearch, getPreviousResearch, 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';
|
|
@@ -62,13 +62,23 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
62
62
|
constructor(deps: AgentDeps) {
|
|
63
63
|
super(deps);
|
|
64
64
|
this.experienceTracker = deps.stateManager.getExperienceTracker();
|
|
65
|
+
this.settings.reasoning ??= 'low';
|
|
66
|
+
}
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
get settings(): ResearcherAgentConfig {
|
|
69
|
+
return agentSettings(this.config, 'researcher');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isEnabled(): boolean {
|
|
73
|
+
return this.settings.enabled !== false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
enable(): void {
|
|
77
|
+
this.settings.enabled = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
disable(): void {
|
|
81
|
+
this.settings.enabled = false;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
protected getNavigator(): Navigator {
|
|
@@ -94,7 +104,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
94
104
|
|
|
95
105
|
async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
|
|
96
106
|
const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
|
|
97
|
-
const maxRetries =
|
|
107
|
+
const maxRetries = this.settings.retries ?? 2;
|
|
98
108
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
99
109
|
this.actionResult = ActionResult.fromState(state);
|
|
100
110
|
const stateHash = this.actionResult.baseHash;
|
|
@@ -109,6 +119,13 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
109
119
|
}
|
|
110
120
|
}
|
|
111
121
|
|
|
122
|
+
if (!this.isEnabled()) {
|
|
123
|
+
debugLog('Researcher is disabled, answering with the recorded map');
|
|
124
|
+
const recorded = getPreviousResearch(stateHash);
|
|
125
|
+
if (recorded) reportResearch(stateHash, recorded);
|
|
126
|
+
return recorded;
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
Stats.researches++;
|
|
113
130
|
|
|
114
131
|
const sessionName = `researcher: ${state.url}`;
|
|
@@ -341,7 +358,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
341
358
|
}
|
|
342
359
|
|
|
343
360
|
private async waitUntilSettled(screenshot: boolean): Promise<boolean> {
|
|
344
|
-
const errorPageTimeout =
|
|
361
|
+
const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
|
|
345
362
|
if (errorPageTimeout <= 0) return false;
|
|
346
363
|
|
|
347
364
|
const includeScreenshot = screenshot && this.provider.hasVision();
|
|
@@ -374,7 +391,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
374
391
|
}
|
|
375
392
|
|
|
376
393
|
private getConfiguredSections(): Record<string, string> {
|
|
377
|
-
const configSections =
|
|
394
|
+
const configSections = this.settings.sections;
|
|
378
395
|
if (!configSections?.length) return POSSIBLE_SECTIONS;
|
|
379
396
|
const filtered: Record<string, string> = {};
|
|
380
397
|
for (const key of configSections) {
|
package/src/ai/tester.ts
CHANGED
|
@@ -53,6 +53,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
53
53
|
MAX_ITERATIONS = 30;
|
|
54
54
|
MAX_EXTENSIONS = 2;
|
|
55
55
|
ASSERTION_TOOLS = ['verify'];
|
|
56
|
+
private pendingReview = '';
|
|
56
57
|
researcher: Researcher;
|
|
57
58
|
navigator: Navigator;
|
|
58
59
|
agentTools: any;
|
|
@@ -119,6 +120,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
119
120
|
this.seenUiMapUrls.clear();
|
|
120
121
|
this.lastAnalyzedStateHash = null;
|
|
121
122
|
this.stalledIterations = 0;
|
|
123
|
+
this.pendingReview = '';
|
|
122
124
|
this.previousRegionPresent = null;
|
|
123
125
|
this.regionTransitioned = false;
|
|
124
126
|
this.stateManager.clearHistory();
|
|
@@ -333,7 +335,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
333
335
|
const result = await this.provider.invokeConversation(conversation, tools, {
|
|
334
336
|
maxToolRoundtrips: 3,
|
|
335
337
|
toolChoice: 'required',
|
|
336
|
-
stopWhen: () => task.hasFinished,
|
|
338
|
+
stopWhen: () => task.hasFinished || !!this.pendingReview,
|
|
337
339
|
});
|
|
338
340
|
|
|
339
341
|
if (!result) throw new Error('Failed to get response from provider');
|
|
@@ -388,6 +390,14 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
388
390
|
}
|
|
389
391
|
}
|
|
390
392
|
|
|
393
|
+
if (this.pendingReview && this.pilot) {
|
|
394
|
+
const reviewed = this.pendingReview;
|
|
395
|
+
this.pendingReview = '';
|
|
396
|
+
const reviewState = this.getCurrentState();
|
|
397
|
+
if (reviewed === 'finish') await this.pilot.reviewFinish(task, reviewState, conversation, this.navigator);
|
|
398
|
+
if (reviewed === 'stop') await this.pilot.reviewStop(task, reviewState, conversation);
|
|
399
|
+
}
|
|
400
|
+
|
|
391
401
|
if (task.hasFinished) {
|
|
392
402
|
stop();
|
|
393
403
|
return;
|
|
@@ -579,16 +589,19 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
579
589
|
|
|
580
590
|
if (region.isModal) {
|
|
581
591
|
const areaName = region.name ? ` "${region.name}"` : '';
|
|
582
|
-
let
|
|
583
|
-
if (region.root)
|
|
592
|
+
let scoping = 'Use <page_aria> to confirm the element you target is actually inside the overlay.';
|
|
593
|
+
if (region.root) {
|
|
594
|
+
scoping = `Its root is \`${region.root}\` — build every locator as ARIA scoped to that root, e.g. I.click({ role: 'button', text: 'Continue' }, '${region.root}')`;
|
|
595
|
+
}
|
|
584
596
|
context += dedent`
|
|
585
597
|
<overlay>
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
Use <page_aria> to confirm the element you target is actually inside the overlay.
|
|
590
|
-
</overlay>
|
|
598
|
+
You are inside an overlay${areaName} opened above the page.
|
|
599
|
+
${scoping}
|
|
600
|
+
Elements outside the overlay are behind it and not actionable while it is open — they may share names or roles with the ones inside, so never target them by bare text.
|
|
591
601
|
`;
|
|
602
|
+
const regionAria = currentState.getRegionARIA();
|
|
603
|
+
if (regionAria) context += `\nIt holds exactly these elements:\n<overlay_aria>\n${regionAria}\n</overlay_aria>`;
|
|
604
|
+
context += '\n</overlay>\n';
|
|
592
605
|
}
|
|
593
606
|
|
|
594
607
|
if (!region.isModal && region.isOpen && isNewState) {
|
|
@@ -1006,18 +1019,9 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1006
1019
|
}),
|
|
1007
1020
|
execute: async ({ reason }) => {
|
|
1008
1021
|
task.addNote(`Stop requested: ${reason}`);
|
|
1022
|
+
this.pendingReview = 'stop';
|
|
1009
1023
|
|
|
1010
|
-
if (this.pilot) {
|
|
1011
|
-
const currentState = this.getCurrentState();
|
|
1012
|
-
await this.pilot.reviewStop(task, currentState, conversation);
|
|
1013
|
-
if (!task.hasFinished) {
|
|
1014
|
-
return {
|
|
1015
|
-
success: false,
|
|
1016
|
-
action: 'stop',
|
|
1017
|
-
message: 'Stop rejected; Continue execution',
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
} else {
|
|
1024
|
+
if (!this.pilot) {
|
|
1021
1025
|
task.addNote(reason, TestResult.FAILED);
|
|
1022
1026
|
task.finish(TestResult.FAILED);
|
|
1023
1027
|
}
|
|
@@ -1053,18 +1057,9 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1053
1057
|
return { success: true, action: 'finish', message: 'already finished' };
|
|
1054
1058
|
}
|
|
1055
1059
|
task.addNote(`Finish requested: ${verify}`);
|
|
1060
|
+
this.pendingReview = 'finish';
|
|
1056
1061
|
|
|
1057
|
-
if (this.pilot) {
|
|
1058
|
-
const currentState = this.getCurrentState();
|
|
1059
|
-
await this.pilot.reviewFinish(task, currentState, conversation, this.navigator);
|
|
1060
|
-
if (!task.hasFinished) {
|
|
1061
|
-
return {
|
|
1062
|
-
success: false,
|
|
1063
|
-
action: 'finish',
|
|
1064
|
-
message: 'Finishing rejected; Continue execution',
|
|
1065
|
-
};
|
|
1066
|
-
}
|
|
1067
|
-
} else {
|
|
1062
|
+
if (!this.pilot) {
|
|
1068
1063
|
task.addNote('Test finished successfully', TestResult.PASSED);
|
|
1069
1064
|
task.finish(TestResult.PASSED);
|
|
1070
1065
|
}
|
package/src/ai/tools.ts
CHANGED
|
@@ -737,6 +737,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
737
737
|
|
|
738
738
|
const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
|
|
739
739
|
|
|
740
|
+
if (!researchResult) {
|
|
741
|
+
return failedToolResult('research', 'No UI map is available for this page.', {
|
|
742
|
+
suggestion: 'Use context() to read the page structure and act on the elements it lists.',
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
740
746
|
return successToolResult('research', {
|
|
741
747
|
analysis: researchResult,
|
|
742
748
|
aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
|
|
@@ -45,7 +45,10 @@ export class ConfigCommand extends BaseCommand {
|
|
|
45
45
|
const env: Record<string, string> = {};
|
|
46
46
|
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
47
47
|
const value = process.env[variable.name];
|
|
48
|
-
if (value)
|
|
48
|
+
if (!value) continue;
|
|
49
|
+
let shown = value;
|
|
50
|
+
if (variable.secret) shown = 'set';
|
|
51
|
+
env[variable.name] = shown;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const models: Record<string, string> = {};
|
package/src/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
|
22
22
|
export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
|
|
23
23
|
|
|
24
24
|
let cachedOutputRoot: string | null = null;
|
|
25
|
+
let runOutputDir: string | null = null;
|
|
25
26
|
|
|
26
27
|
interface PlaywrightConfig {
|
|
27
28
|
browser: 'chromium' | 'firefox' | 'webkit';
|
|
@@ -275,6 +276,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
|
275
276
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
276
277
|
{ name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
|
|
277
278
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
279
|
+
{ name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
|
|
278
280
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
279
281
|
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
280
282
|
];
|
|
@@ -496,6 +498,7 @@ export class ConfigParser {
|
|
|
496
498
|
// For testing purposes only
|
|
497
499
|
public static resetForTesting(): void {
|
|
498
500
|
cachedOutputRoot = null;
|
|
501
|
+
runOutputDir = null;
|
|
499
502
|
if (ConfigParser.instance) {
|
|
500
503
|
ConfigParser.instance.config = null;
|
|
501
504
|
ConfigParser.instance.configPath = null;
|
|
@@ -744,10 +747,22 @@ export class ConfigParser {
|
|
|
744
747
|
}
|
|
745
748
|
}
|
|
746
749
|
|
|
750
|
+
export function setOutputDir(dir: string): void {
|
|
751
|
+
runOutputDir = dir;
|
|
752
|
+
}
|
|
753
|
+
|
|
747
754
|
export function outputPath(...segments: string[]): string {
|
|
755
|
+
if (runOutputDir) return path.join(runOutputDir, ...segments);
|
|
748
756
|
return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
|
|
749
757
|
}
|
|
750
758
|
|
|
759
|
+
export function agentSettings<K extends keyof AgentsConfig>(config: ExplorbotConfig, agent: K): NonNullable<AgentsConfig[K]> {
|
|
760
|
+
const ai = (config.ai ??= { model: null });
|
|
761
|
+
const agents = (ai.agents ??= {}) as Record<K, NonNullable<AgentsConfig[K]>>;
|
|
762
|
+
agents[agent] ??= {} as NonNullable<AgentsConfig[K]>;
|
|
763
|
+
return agents[agent];
|
|
764
|
+
}
|
|
765
|
+
|
|
751
766
|
export async function resolveModel(spec: string, role: ModelRole = 'model'): Promise<any> {
|
|
752
767
|
const separator = spec.indexOf('/');
|
|
753
768
|
if (separator > 0) {
|
|
@@ -912,6 +927,7 @@ interface EnvVar {
|
|
|
912
927
|
name: string;
|
|
913
928
|
description: string;
|
|
914
929
|
required?: boolean;
|
|
930
|
+
secret?: boolean;
|
|
915
931
|
}
|
|
916
932
|
|
|
917
933
|
export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
|
package/src/state-manager.ts
CHANGED
|
@@ -143,13 +143,18 @@ export class StateManager {
|
|
|
143
143
|
updateState(actionResult: ActionResult, codeBlock?: string, trigger: 'manual' | 'navigation' | 'automatic' = 'manual'): WebPageState {
|
|
144
144
|
const previousState = this.currentState;
|
|
145
145
|
const previousHash = previousState?.hash;
|
|
146
|
+
const hashChanged = actionResult.hash !== previousHash;
|
|
147
|
+
|
|
148
|
+
if (!hashChanged && previousState?.verifications) {
|
|
149
|
+
const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
|
|
150
|
+
actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
|
|
151
|
+
}
|
|
146
152
|
|
|
147
153
|
const newState = actionResult;
|
|
148
154
|
this.currentState = newState;
|
|
149
155
|
this.currentState.id = this.nextStateId++;
|
|
150
156
|
if (newState.url) this.allVisitedUrls.add(normalizeUrl(newState.url));
|
|
151
157
|
|
|
152
|
-
const hashChanged = actionResult.hash !== previousHash;
|
|
153
158
|
const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
|
|
154
159
|
|
|
155
160
|
if (hashChanged || regionOpened) {
|