explorbot 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +2 -1
- package/boat/prima/README.md +31 -4
- package/boat/prima/bin/prima-cli.ts +2 -1
- package/dist/bin/explorbot-cli.js +2 -1
- package/dist/boat/prima/bin/prima-cli.js +2 -1
- package/dist/package.json +2 -2
- package/dist/src/action.d.ts +1 -0
- package/dist/src/action.js +7 -0
- package/dist/src/ai/judge-provider.js +5 -0
- package/dist/src/ai/judge.d.ts +3 -1
- package/dist/src/ai/judge.js +29 -6
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +17 -4
- package/dist/src/ai/provider.js +15 -8
- package/dist/src/ai/rules.js +13 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +11 -4
- package/dist/src/ai/tools.js +18 -9
- package/dist/src/commands/options/decision-model-option.d.ts +6 -0
- package/dist/src/commands/options/decision-model-option.js +10 -0
- package/dist/src/commands/options/index.d.ts +2 -0
- package/dist/src/commands/options/index.js +2 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +11 -0
- package/docs/basics/providers.md +38 -0
- package/docs/index.json +1 -0
- package/docs/reference/commands.md +1 -0
- package/docs/reference/configuration.md +2 -0
- package/docs/web-testing/decisions.md +119 -0
- package/docs/workflow/agentic-usage.md +1 -0
- package/package.json +2 -2
- package/src/action.ts +8 -0
- package/src/ai/judge-provider.ts +6 -0
- package/src/ai/judge.ts +25 -6
- package/src/ai/pilot.ts +17 -4
- package/src/ai/provider.ts +14 -8
- package/src/ai/rules.ts +13 -0
- package/src/ai/tester.ts +11 -3
- package/src/ai/tools.ts +22 -11
- package/src/commands/options/decision-model-option.ts +11 -0
- package/src/commands/options/index.ts +2 -0
- package/src/config.ts +11 -1
package/bin/explorbot-cli.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { HelpJsonCommand } from '../src/commands/help-json-command.js';
|
|
|
12
12
|
import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
|
|
13
13
|
import { App } from '../src/components/App.js';
|
|
14
14
|
import { StatusPane } from '../src/components/StatusPane.js';
|
|
15
|
-
import { knowledgeOption, wsOption } from '../src/commands/options/index.js';
|
|
15
|
+
import { decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
|
|
16
16
|
import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
|
|
17
17
|
import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
|
|
18
18
|
import { remote } from '../src/remote.js';
|
|
@@ -33,6 +33,7 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version as stri
|
|
|
33
33
|
program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
|
|
34
34
|
wsOption.register(program);
|
|
35
35
|
knowledgeOption.register(program);
|
|
36
|
+
decisionModelOption.register(program);
|
|
36
37
|
|
|
37
38
|
process.on('uncaughtException', async (error) => {
|
|
38
39
|
tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
|
package/boat/prima/README.md
CHANGED
|
@@ -61,22 +61,49 @@ prima-cli browser stop
|
|
|
61
61
|
|
|
62
62
|
Prima needs a model, and takes it from the environment. There is no `init` to run and nothing is written for you.
|
|
63
63
|
|
|
64
|
+
The setup we recommend runs everything through OpenRouter with one key: GPT-OSS 120B served by Groq as the main model, and TypeSafe's Jev as the [decision model](https://github.com/testomatio/explorbot/blob/main/docs/web-testing/decisions.md), which answers narrow yes/no and pick-one questions so the main model is called less often.
|
|
65
|
+
|
|
64
66
|
```bash
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
PRIMA_CLI_AI_MODEL=openrouter/openai/gpt-oss-120b:nitro
|
|
68
|
+
PRIMA_CLI_DECISION_MODEL=openrouter/typesafe/jev-1.13
|
|
69
|
+
OPENROUTER_API_KEY=your-key
|
|
67
70
|
```
|
|
68
71
|
|
|
69
|
-
The provider comes from
|
|
72
|
+
The provider comes from each model name, and `:nitro` asks OpenRouter for its fastest host, which is Groq for this model. Jev is optional: leave `PRIMA_CLI_DECISION_MODEL` unset and prima never calls it, or pass `--decision-model` for a single run.
|
|
73
|
+
|
|
74
|
+
Prima is usually run by a coding agent, so the best home for these variables is the agent's own config, where they reach every command the agent runs.
|
|
75
|
+
|
|
76
|
+
Claude Code, in `~/.claude/settings.json`:
|
|
70
77
|
|
|
71
78
|
```json
|
|
72
79
|
{
|
|
73
80
|
"env": {
|
|
74
|
-
"PRIMA_CLI_AI_MODEL": "openrouter/openai/gpt-oss-120b",
|
|
81
|
+
"PRIMA_CLI_AI_MODEL": "openrouter/openai/gpt-oss-120b:nitro",
|
|
82
|
+
"PRIMA_CLI_DECISION_MODEL": "openrouter/typesafe/jev-1.13",
|
|
75
83
|
"OPENROUTER_API_KEY": "your-key"
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
```
|
|
79
87
|
|
|
88
|
+
Codex, in `~/.codex/config.toml`:
|
|
89
|
+
|
|
90
|
+
```toml
|
|
91
|
+
[shell_environment_policy]
|
|
92
|
+
set = { PRIMA_CLI_AI_MODEL = "openrouter/openai/gpt-oss-120b:nitro", PRIMA_CLI_DECISION_MODEL = "openrouter/typesafe/jev-1.13", OPENROUTER_API_KEY = "your-key" }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`set` is applied after Codex filters the inherited environment, so the key survives even where names containing `KEY` are stripped.
|
|
96
|
+
|
|
97
|
+
To run prima yourself, or from any agent that inherits your shell, export them in `~/.bashrc` or `~/.zshrc`:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export PRIMA_CLI_AI_MODEL=openrouter/openai/gpt-oss-120b:nitro
|
|
101
|
+
export PRIMA_CLI_DECISION_MODEL=openrouter/typesafe/jev-1.13
|
|
102
|
+
export OPENROUTER_API_KEY=your-key
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Fish uses `set -gx` in `~/.config/fish/config.fish` instead of `export`.
|
|
106
|
+
|
|
80
107
|
Screenshot analysis needs its own model — one is never guessed from the main model. Set `PRIMA_CLI_VISION_MODEL`, or pass `--vision-model` for a single run, as `--model` does for the main one. Without it prima still runs, but settles `check` outcomes from the run log rather than from the page as seen.
|
|
81
108
|
|
|
82
109
|
Every `PRIMA_CLI_*` variable mirrors the `EXPLORBOT_*` one of the same name and wins over it, so prima can be pointed at a different provider, model or URL without disturbing an explorbot setup on the same machine. `PRIMA_CLI_AI_MODEL`, `PRIMA_CLI_URL`, `PRIMA_CLI_VISION_MODEL` and the rest all work this way.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { knowledgeOption } from '../../../src/commands/options/index.ts';
|
|
2
|
+
import { decisionModelOption, knowledgeOption } from '../../../src/commands/options/index.ts';
|
|
3
3
|
import { createPrimaCommands } from '../src/cli.ts';
|
|
4
4
|
|
|
5
5
|
const program = createPrimaCommands('prima');
|
|
6
6
|
knowledgeOption.register(program);
|
|
7
|
+
decisionModelOption.register(program);
|
|
7
8
|
program.parse();
|
|
@@ -12,7 +12,7 @@ import { HelpJsonCommand } from '../src/commands/help-json-command.js';
|
|
|
12
12
|
import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
|
|
13
13
|
import { App } from '../src/components/App.js';
|
|
14
14
|
import { StatusPane } from '../src/components/StatusPane.js';
|
|
15
|
-
import { knowledgeOption, wsOption } from '../src/commands/options/index.js';
|
|
15
|
+
import { decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
|
|
16
16
|
import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
|
|
17
17
|
import { ExplorBot } from '../src/explorbot.js';
|
|
18
18
|
import { remote } from '../src/remote.js';
|
|
@@ -30,6 +30,7 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
|
|
|
30
30
|
program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
|
|
31
31
|
wsOption.register(program);
|
|
32
32
|
knowledgeOption.register(program);
|
|
33
|
+
decisionModelOption.register(program);
|
|
33
34
|
process.on('uncaughtException', async (error) => {
|
|
34
35
|
tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
|
|
35
36
|
await flushTelemetry();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { knowledgeOption } from "../../../src/commands/options/index.js";
|
|
2
|
+
import { decisionModelOption, knowledgeOption } from "../../../src/commands/options/index.js";
|
|
3
3
|
import { createPrimaCommands } from "../src/cli.js";
|
|
4
4
|
const program = createPrimaCommands('prima');
|
|
5
5
|
knowledgeOption.register(program);
|
|
6
|
+
decisionModelOption.register(program);
|
|
6
7
|
program.parse();
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"bash-tool": "^1.3.15",
|
|
103
103
|
"chalk": "^5.6.2",
|
|
104
104
|
"cli-highlight": "^2.1.11",
|
|
105
|
-
"codeceptjs": "^4.2.0
|
|
105
|
+
"codeceptjs": "^4.2.0",
|
|
106
106
|
"commander": "^14.0.1",
|
|
107
107
|
"debug": "^4.4.3",
|
|
108
108
|
"dedent": "^1.6.0",
|
package/dist/src/action.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ declare class Action {
|
|
|
48
48
|
captureBrowserLogs(): Promise<any[]>;
|
|
49
49
|
executeOnce(code: string, verbatim?: boolean): Promise<Action>;
|
|
50
50
|
attempt(codeBlock: string, originalMessage?: string): Promise<boolean>;
|
|
51
|
+
attemptExactElementIndex(codeBlock: string, elementIndex: number, originalMessage?: string): Promise<boolean>;
|
|
51
52
|
exitIframe(): Promise<void>;
|
|
52
53
|
getActor(): CodeceptJS.I;
|
|
53
54
|
getActionResult(): ActionResult | null;
|
package/dist/src/action.js
CHANGED
|
@@ -461,6 +461,13 @@ class Action {
|
|
|
461
461
|
return false;
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
|
+
async attemptExactElementIndex(codeBlock, elementIndex, originalMessage) {
|
|
465
|
+
const applyElementIndex = (step) => {
|
|
466
|
+
step.opts = { ...step.opts, elementIndex };
|
|
467
|
+
};
|
|
468
|
+
codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
|
|
469
|
+
return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
|
|
470
|
+
}
|
|
464
471
|
async exitIframe() {
|
|
465
472
|
if (!this.playwrightHelper.frame)
|
|
466
473
|
return;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Observability } from "../observability.js";
|
|
2
|
+
import { redactSecrets } from "../utils/secrets.js";
|
|
1
3
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
2
4
|
export class JudgeProvider {
|
|
3
5
|
model;
|
|
@@ -19,6 +21,9 @@ export class JudgeProvider {
|
|
|
19
21
|
if (options)
|
|
20
22
|
q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
|
|
21
23
|
const body = JSON.stringify({ model: this.model, state, questions: { q } });
|
|
24
|
+
const span = Observability.getSpan();
|
|
25
|
+
span?.setAttribute('langfuse.observation.input', redactSecrets(body));
|
|
26
|
+
span?.setAttribute('ai.telemetry.metadata.judgeEndpoint', this.url);
|
|
22
27
|
const controller = new AbortController();
|
|
23
28
|
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
24
29
|
try {
|
package/dist/src/ai/judge.d.ts
CHANGED
|
@@ -15,13 +15,15 @@ export declare class Judge {
|
|
|
15
15
|
tool: boolean;
|
|
16
16
|
direct: boolean;
|
|
17
17
|
};
|
|
18
|
+
threshold: number;
|
|
18
19
|
constructor(provider: JudgeProvider, enabled: {
|
|
19
20
|
tool: boolean;
|
|
20
21
|
direct: boolean;
|
|
21
|
-
});
|
|
22
|
+
}, threshold?: number);
|
|
22
23
|
static fromConfig(config: AIConfig['decisionModel']): Judge | null;
|
|
23
24
|
get toolEnabled(): boolean;
|
|
24
25
|
decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
|
|
26
|
+
pick(question: string, options: string[], state: unknown): Promise<number | null>;
|
|
25
27
|
consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
|
|
26
28
|
request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
|
|
27
29
|
recordFailure(error: unknown): null;
|
package/dist/src/ai/judge.js
CHANGED
|
@@ -5,6 +5,7 @@ import { JudgeProvider } from "./judge-provider.js";
|
|
|
5
5
|
const debugLog = createDebug('explorbot:judge');
|
|
6
6
|
const APPROVAL_THRESHOLD = 0.7;
|
|
7
7
|
export const UNDECIDED = 'undecided';
|
|
8
|
+
const UNDECIDED_HINT = `Choose "${UNDECIDED}" if you are not sure or no option fits.`;
|
|
8
9
|
export const JUDGE_PAGE_CAP = 12000;
|
|
9
10
|
export class Decision {
|
|
10
11
|
value;
|
|
@@ -23,14 +24,19 @@ export class Decision {
|
|
|
23
24
|
export class Judge {
|
|
24
25
|
provider;
|
|
25
26
|
enabled;
|
|
26
|
-
|
|
27
|
+
threshold;
|
|
28
|
+
constructor(provider, enabled, threshold = APPROVAL_THRESHOLD) {
|
|
27
29
|
this.provider = provider;
|
|
28
30
|
this.enabled = enabled;
|
|
31
|
+
this.threshold = threshold;
|
|
29
32
|
}
|
|
30
33
|
static fromConfig(config) {
|
|
31
34
|
if (!config)
|
|
32
35
|
return null;
|
|
33
|
-
|
|
36
|
+
const threshold = config.threshold ?? APPROVAL_THRESHOLD;
|
|
37
|
+
if (!(threshold > 0 && threshold < 1))
|
|
38
|
+
throw new Error(`decisionModel.threshold must be between 0 and 1, got ${config.threshold}`);
|
|
39
|
+
return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false }, threshold);
|
|
34
40
|
}
|
|
35
41
|
get toolEnabled() {
|
|
36
42
|
return this.enabled.tool;
|
|
@@ -40,13 +46,23 @@ export class Judge {
|
|
|
40
46
|
return new Decision(null, 0);
|
|
41
47
|
return this.consult(question, options, state);
|
|
42
48
|
}
|
|
49
|
+
async pick(question, options, state) {
|
|
50
|
+
if (new Set(options).size < options.length)
|
|
51
|
+
return null;
|
|
52
|
+
const decision = await this.decide(question, [...options, UNDECIDED], state);
|
|
53
|
+
if (!decision.value)
|
|
54
|
+
return null;
|
|
55
|
+
return options.indexOf(decision.value) + 1;
|
|
56
|
+
}
|
|
43
57
|
async consult(question, options, state) {
|
|
44
58
|
if (Array.isArray(options) && options.length < 2)
|
|
45
59
|
return new Decision(null, 0);
|
|
46
60
|
return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
|
|
47
61
|
setActivity('⚖️ Asking judge...', 'ai');
|
|
48
62
|
const decision = await this.request(question, options, state).finally(() => clearActivity());
|
|
49
|
-
Observability.getSpan()
|
|
63
|
+
const span = Observability.getSpan();
|
|
64
|
+
span?.setAttribute('ai.telemetry.metadata.judgeQuestion', question);
|
|
65
|
+
span?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence, threshold: this.threshold, model: this.provider.model }));
|
|
50
66
|
return decision;
|
|
51
67
|
});
|
|
52
68
|
}
|
|
@@ -54,10 +70,14 @@ export class Judge {
|
|
|
54
70
|
let list;
|
|
55
71
|
if (Array.isArray(options))
|
|
56
72
|
list = options;
|
|
57
|
-
|
|
73
|
+
let instructions = question;
|
|
74
|
+
if (list?.includes(UNDECIDED))
|
|
75
|
+
instructions = `${question} ${UNDECIDED_HINT}`;
|
|
76
|
+
const answer = await this.provider.decide(state, instructions, list).catch((error) => this.recordFailure(error));
|
|
58
77
|
if (!answer)
|
|
59
78
|
return new Decision(null, 0);
|
|
60
|
-
|
|
79
|
+
Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
|
|
80
|
+
if (answer.probability <= this.threshold)
|
|
61
81
|
return new Decision(null, answer.probability);
|
|
62
82
|
if (answer.value === UNDECIDED)
|
|
63
83
|
return new Decision(null, answer.probability);
|
|
@@ -65,7 +85,10 @@ export class Judge {
|
|
|
65
85
|
}
|
|
66
86
|
recordFailure(error) {
|
|
67
87
|
debugLog('judge declined: %s', error);
|
|
68
|
-
Observability.getSpan()
|
|
88
|
+
const span = Observability.getSpan();
|
|
89
|
+
span?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
|
|
90
|
+
span?.setAttribute('langfuse.observation.level', 'ERROR');
|
|
91
|
+
span?.setAttribute('langfuse.observation.status_message', String(error));
|
|
69
92
|
return null;
|
|
70
93
|
}
|
|
71
94
|
}
|
package/dist/src/ai/pilot.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export declare class Pilot implements Agent {
|
|
|
41
41
|
buildVerdictSystemPrompt(task: Test): string;
|
|
42
42
|
planTest(task: Test, currentState: ActionResult): Promise<string>;
|
|
43
43
|
reviewNewPage(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
44
|
+
periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
44
45
|
analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
45
46
|
settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
|
|
46
47
|
settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>>;
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -11,7 +11,7 @@ import { createDebug, tag } from "../utils/logger.js";
|
|
|
11
11
|
const debugLog = createDebug('explorbot:pilot');
|
|
12
12
|
import { truncateJson } from "../utils/strings.js";
|
|
13
13
|
import { createAskApiTool } from "./fisherman/tools.js";
|
|
14
|
-
import { UNDECIDED } from "./judge.js";
|
|
14
|
+
import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
|
|
15
15
|
import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
|
|
16
16
|
import { isInteractive } from "./task-agent.js";
|
|
17
17
|
import { withdrawVisionTools } from "./tools.js";
|
|
@@ -477,6 +477,22 @@ export class Pilot {
|
|
|
477
477
|
Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
|
|
478
478
|
`, 'pilot.reviewNewPage', { tools: true, maxToolRoundtrips: 2, task });
|
|
479
479
|
}
|
|
480
|
+
async periodicAnalyzeProgress(task, currentState, testerConversation) {
|
|
481
|
+
const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
|
|
482
|
+
const healthy = await this.judge?.decide('The recent actions advance the scenario toward its remaining expected outcomes.', null, {
|
|
483
|
+
scenario: task.scenario,
|
|
484
|
+
plannedSteps: task.plannedSteps,
|
|
485
|
+
expectations: this.formatExpectations(task),
|
|
486
|
+
runLog: task.notesToString() || 'No steps recorded.',
|
|
487
|
+
visitedUrls: task.getVisitedUrls({ localOnly: true }),
|
|
488
|
+
state: this.buildStateContext(currentState),
|
|
489
|
+
page: currentState.getCompactARIA().slice(0, JUDGE_PAGE_CAP),
|
|
490
|
+
recentActions: this.formatActions(toolCalls),
|
|
491
|
+
});
|
|
492
|
+
if (healthy?.approved)
|
|
493
|
+
return '';
|
|
494
|
+
return this.analyzeProgress(task, currentState, testerConversation);
|
|
495
|
+
}
|
|
480
496
|
async analyzeProgress(task, currentState, testerConversation) {
|
|
481
497
|
tag('substep').log('Pilot analyzing progress...');
|
|
482
498
|
if (!this.conversation) {
|
|
@@ -487,9 +503,6 @@ export class Pilot {
|
|
|
487
503
|
const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
|
|
488
504
|
const actionsContext = this.formatActions(toolCalls);
|
|
489
505
|
const stateContext = this.buildStateContext(currentState);
|
|
490
|
-
const healthy = await this.judge?.decide('The run is moving toward the goal and can continue without a supervisor reviewing it now.', null, { scenario: task.scenario, state: stateContext, recentActions: actionsContext });
|
|
491
|
-
if (healthy?.approved)
|
|
492
|
-
return '';
|
|
493
506
|
const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
|
|
494
507
|
const text = await this.sendToPilot(dedent `
|
|
495
508
|
START URL: ${task.startUrl}
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
2
3
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
4
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
-
import dedent from 'dedent';
|
|
6
5
|
import { APICallError, NoObjectGeneratedError, asSchema, extractJsonMiddleware, generateObject, generateText, isStepCount, parsePartialJson, registerTelemetry, tool, wrapLanguageModel } from 'ai';
|
|
6
|
+
import dedent from 'dedent';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { clearActivity, setActivity } from "../activity.js";
|
|
9
9
|
import { configuredModels, modelName as getModelName } from '../config.js';
|
|
@@ -51,15 +51,16 @@ const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum
|
|
|
51
51
|
function extractCachedTokens(usage) {
|
|
52
52
|
return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
53
53
|
}
|
|
54
|
-
function abortAfterIdle(ms,
|
|
54
|
+
function abortAfterIdle(ms, state, controller, busy) {
|
|
55
55
|
return new Promise((_, reject) => {
|
|
56
56
|
const tick = () => {
|
|
57
|
-
if (
|
|
57
|
+
if (state.cancelled)
|
|
58
58
|
return;
|
|
59
59
|
if (executionController.isAwaitingInput() || busy.tools > 0) {
|
|
60
60
|
setTimeout(tick, ms);
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
|
+
state.timedOut = true;
|
|
63
64
|
controller.abort();
|
|
64
65
|
reject(new Error('AI request timeout'));
|
|
65
66
|
};
|
|
@@ -232,14 +233,20 @@ export class Provider {
|
|
|
232
233
|
});
|
|
233
234
|
}
|
|
234
235
|
async raceWithIdleTimeout(fn, timeoutMs, busy = { tools: 0 }) {
|
|
235
|
-
const
|
|
236
|
+
const state = { cancelled: false, timedOut: false };
|
|
236
237
|
const controller = new AbortController();
|
|
237
238
|
const combinedSignal = combinedAbortSignal(controller);
|
|
238
239
|
try {
|
|
239
|
-
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs,
|
|
240
|
+
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, state, controller, busy)]);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (state.timedOut && !executionController.getAbortSignal()?.aborted) {
|
|
244
|
+
throw new Error('AI request timeout', { cause: error });
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
240
247
|
}
|
|
241
248
|
finally {
|
|
242
|
-
|
|
249
|
+
state.cancelled = true;
|
|
243
250
|
}
|
|
244
251
|
}
|
|
245
252
|
async recoverFromContextLength(error, messages, options, retry) {
|
|
@@ -615,7 +622,7 @@ export class Provider {
|
|
|
615
622
|
if (!this.config.visionModel) {
|
|
616
623
|
throw new Error(`Vision model not configured. Set ai.visionModel in your config; ${getCliName()} recommended-models lists the providers that serve one`);
|
|
617
624
|
}
|
|
618
|
-
setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
|
|
625
|
+
setActivity(`🤖 Processing image with ${getModelName(this.config.visionModel)}`, 'ai');
|
|
619
626
|
const messages = [
|
|
620
627
|
{
|
|
621
628
|
role: 'user',
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -480,6 +480,19 @@ export const actionRule = dedent `
|
|
|
480
480
|
|
|
481
481
|
After hovering, use see() or context() to check what appeared.
|
|
482
482
|
|
|
483
|
+
### I.seeInClipboard / I.seeClipboardEquals / I.clearClipboard
|
|
484
|
+
|
|
485
|
+
Check what a copy control put into the system clipboard. Run via form(), not click().
|
|
486
|
+
I.seeInClipboard asserts the clipboard contains the text, I.seeClipboardEquals asserts an exact match,
|
|
487
|
+
I.clearClipboard empties it — call it before copying so a stale value can't pass the check.
|
|
488
|
+
|
|
489
|
+
<example>
|
|
490
|
+
I.clearClipboard();
|
|
491
|
+
I.click({ role: 'button', text: 'Copy link' });
|
|
492
|
+
I.seeInClipboard('/invite/');
|
|
493
|
+
I.seeClipboardEquals('ABC-123');
|
|
494
|
+
</example>
|
|
495
|
+
|
|
483
496
|
[DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
|
|
484
497
|
[INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML]
|
|
485
498
|
[DO NOT USE WAIT FUNCTIONS]
|
package/dist/src/ai/tester.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
51
51
|
success: boolean;
|
|
52
52
|
}>;
|
|
53
53
|
shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean;
|
|
54
|
+
get isStruggling(): boolean;
|
|
54
55
|
shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean;
|
|
55
56
|
prepareInstructionsForNextStep(task: Test): Promise<string>;
|
|
56
57
|
reinjectContextIfNeeded(iteration: number, currentState: ActionResult): Promise<string>;
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -285,7 +285,13 @@ export class Tester extends TaskAgent {
|
|
|
285
285
|
nextStep += `\n\n${guidance}`;
|
|
286
286
|
}
|
|
287
287
|
else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
|
|
288
|
-
|
|
288
|
+
let guidance;
|
|
289
|
+
if (this.isStruggling) {
|
|
290
|
+
guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
|
|
294
|
+
}
|
|
289
295
|
if (guidance)
|
|
290
296
|
nextStep += `\n\n${guidance}`;
|
|
291
297
|
this.consecutiveFailures = 0;
|
|
@@ -432,9 +438,7 @@ export class Tester extends TaskAgent {
|
|
|
432
438
|
this.regionTransitioned = false;
|
|
433
439
|
return true;
|
|
434
440
|
}
|
|
435
|
-
if (this.
|
|
436
|
-
return true;
|
|
437
|
-
if (this.consecutiveEmptyResults >= 2)
|
|
441
|
+
if (this.isStruggling)
|
|
438
442
|
return true;
|
|
439
443
|
if (iteration % this.progressCheckInterval !== 0)
|
|
440
444
|
return false;
|
|
@@ -442,6 +446,9 @@ export class Tester extends TaskAgent {
|
|
|
442
446
|
return false;
|
|
443
447
|
return true;
|
|
444
448
|
}
|
|
449
|
+
get isStruggling() {
|
|
450
|
+
return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
|
|
451
|
+
}
|
|
445
452
|
shouldStopForStalledExecution(task, previousState, toolExecutions) {
|
|
446
453
|
if (task.hasFinished)
|
|
447
454
|
return false;
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -14,7 +14,7 @@ import { pause } from '../utils/loop.js';
|
|
|
14
14
|
import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
|
|
15
15
|
import { WebElement } from "../utils/web-element.js";
|
|
16
16
|
import { createJudgeTool } from "./judge-tool.js";
|
|
17
|
-
import { JUDGE_PAGE_CAP
|
|
17
|
+
import { JUDGE_PAGE_CAP } from "./judge.js";
|
|
18
18
|
import { sectionContextRule } from "./rules.js";
|
|
19
19
|
import { isInteractive } from "./task-agent.js";
|
|
20
20
|
const debugLog = createDebug('explorbot:tools');
|
|
@@ -92,13 +92,20 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
92
92
|
let ambiguityError = null;
|
|
93
93
|
for (let i = 0; i < commands.length; i++) {
|
|
94
94
|
const command = transformContainsCommand(commands[i]);
|
|
95
|
-
|
|
95
|
+
let success = await action.attempt(command, explanation);
|
|
96
96
|
const attempt = { command, success };
|
|
97
97
|
if (action.lastError)
|
|
98
98
|
attempt.error = errorText(action.lastError);
|
|
99
99
|
attempts.push(attempt);
|
|
100
|
-
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound')
|
|
100
|
+
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') {
|
|
101
101
|
ambiguityError = action.lastError;
|
|
102
|
+
if (judge) {
|
|
103
|
+
const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
|
|
104
|
+
const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
|
|
105
|
+
if (index)
|
|
106
|
+
success = await action.attemptExactElementIndex(command, index, explanation);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
102
109
|
if (success) {
|
|
103
110
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
|
|
104
111
|
if (!hasObservablePageChange(toolResult)) {
|
|
@@ -127,7 +134,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
127
134
|
...toolResult,
|
|
128
135
|
attempts,
|
|
129
136
|
suggestion,
|
|
130
|
-
}, ambiguityError || action.lastError
|
|
137
|
+
}, ambiguityError || action.lastError);
|
|
131
138
|
},
|
|
132
139
|
}),
|
|
133
140
|
hover: tool({
|
|
@@ -1148,11 +1155,11 @@ export async function failedToolResult(action, message, data, error, judge, inte
|
|
|
1148
1155
|
result.suggestion = getMultipleElementsSuggestion();
|
|
1149
1156
|
result.multipleElementsDetected = true;
|
|
1150
1157
|
result.elements = formatElementList(matched);
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1158
|
+
if (!judge || !matched)
|
|
1159
|
+
return result;
|
|
1160
|
+
const index = await judge.pick(PICK_ELEMENT_QUESTION, matched.map((el) => el.label), { intent });
|
|
1161
|
+
if (!index)
|
|
1154
1162
|
return result;
|
|
1155
|
-
const index = labels.indexOf(pick.value) + 1;
|
|
1156
1163
|
result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
|
|
1157
1164
|
return result;
|
|
1158
1165
|
}
|
|
@@ -1170,6 +1177,7 @@ function getMultipleElementsSuggestion() {
|
|
|
1170
1177
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1171
1178
|
A match reported as not visible can never be acted on — pick one that is.
|
|
1172
1179
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1180
|
+
If the matches cannot be told apart, click the one you mean by appearance with visualClick().
|
|
1173
1181
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1174
1182
|
`;
|
|
1175
1183
|
}
|
|
@@ -1206,6 +1214,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
|
|
|
1206
1214
|
const MAX_DISAMBIGUATE_TEXT = 80;
|
|
1207
1215
|
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1208
1216
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1217
|
+
const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
|
|
1209
1218
|
async function extractWebElements(error) {
|
|
1210
1219
|
if (!error || error.name !== 'MultipleElementsFound')
|
|
1211
1220
|
return null;
|
|
@@ -1219,7 +1228,7 @@ async function extractWebElements(error) {
|
|
|
1219
1228
|
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1220
1229
|
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1221
1230
|
const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
|
|
1222
|
-
result.push({ xpath, html, text, visible });
|
|
1231
|
+
result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
|
|
1223
1232
|
}
|
|
1224
1233
|
catch (e) {
|
|
1225
1234
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BaseOption } from './base-option.js';
|
|
2
|
+
export class DecisionModelOption extends BaseOption {
|
|
3
|
+
flags = '--decision-model <model>';
|
|
4
|
+
description = 'Turn on the decision model for this run, as provider/model-id (e.g. openrouter/typesafe/jev-1.13)';
|
|
5
|
+
apply(options) {
|
|
6
|
+
if (!options.decisionModel)
|
|
7
|
+
return;
|
|
8
|
+
process.env.EXPLORBOT_DECISION_MODEL = options.decisionModel;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { DecisionModelOption } from './decision-model-option.js';
|
|
1
2
|
import { KnowledgeOption } from './knowledge-option.js';
|
|
2
3
|
import { WsOption } from './ws-option.js';
|
|
3
4
|
export { BaseOption } from './base-option.js';
|
|
5
|
+
export declare const decisionModelOption: DecisionModelOption;
|
|
4
6
|
export declare const knowledgeOption: KnowledgeOption;
|
|
5
7
|
export declare const wsOption: WsOption;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { DecisionModelOption } from './decision-model-option.js';
|
|
1
2
|
import { KnowledgeOption } from './knowledge-option.js';
|
|
2
3
|
import { WsOption } from './ws-option.js';
|
|
3
4
|
export { BaseOption } from './base-option.js';
|
|
5
|
+
export const decisionModelOption = new DecisionModelOption();
|
|
4
6
|
export const knowledgeOption = new KnowledgeOption();
|
|
5
7
|
export const wsOption = new WsOption();
|
package/dist/src/config.d.ts
CHANGED
|
@@ -162,6 +162,7 @@ interface AIConfig {
|
|
|
162
162
|
model: string;
|
|
163
163
|
tool?: boolean;
|
|
164
164
|
direct?: boolean;
|
|
165
|
+
threshold?: number;
|
|
165
166
|
};
|
|
166
167
|
retryAttempts?: number;
|
|
167
168
|
retryDelay?: number;
|
|
@@ -270,6 +271,7 @@ export declare class ConfigParser {
|
|
|
270
271
|
static cleanupAllTestDirectories(): void;
|
|
271
272
|
enterGlobalMode(config: ExplorbotConfig, target: string | null): Promise<ExplorbotConfig>;
|
|
272
273
|
applyEnvSpec(config: ExplorbotConfig): void;
|
|
274
|
+
applyEnvDecisionModel(config: ExplorbotConfig): void;
|
|
273
275
|
buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
|
|
274
276
|
findConfigFile(): string | null;
|
|
275
277
|
loadConfigModule(configPath: string): Promise<any>;
|
package/dist/src/config.js
CHANGED
|
@@ -45,6 +45,7 @@ export const EXPLORBOT_ENV_VARS = [
|
|
|
45
45
|
{ name: 'EXPLORBOT_URL', required: true, description: 'Base URL to test; the API boat reads it as the base endpoint' },
|
|
46
46
|
{ name: 'EXPLORBOT_VISION_MODEL', description: 'Screenshot analysis; overrides the provider recommendation' },
|
|
47
47
|
{ name: 'EXPLORBOT_AGENTIC_MODEL', description: 'Captain and Pilot decisions; overrides the provider recommendation' },
|
|
48
|
+
{ name: 'EXPLORBOT_DECISION_MODEL', description: 'Turns on the decision model, as provider/model-id (openrouter/typesafe/jev-1.13 or typesafe/jev-latest); overrides ai.decisionModel' },
|
|
48
49
|
{ name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites' },
|
|
49
50
|
{ name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
|
|
50
51
|
{ name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
|
|
@@ -138,6 +139,7 @@ export class ConfigParser {
|
|
|
138
139
|
this.runtimeTarget = target;
|
|
139
140
|
this.configPath = sourcePath;
|
|
140
141
|
this.applyEnvSpec(this.config);
|
|
142
|
+
this.applyEnvDecisionModel(this.config);
|
|
141
143
|
// Restore original directory after successful config load
|
|
142
144
|
if (options?.path && originalCwd !== process.cwd()) {
|
|
143
145
|
process.chdir(originalCwd);
|
|
@@ -300,6 +302,15 @@ export class ConfigParser {
|
|
|
300
302
|
config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
301
303
|
config.dirs.spec = spec;
|
|
302
304
|
}
|
|
305
|
+
applyEnvDecisionModel(config) {
|
|
306
|
+
const spec = process.env.EXPLORBOT_DECISION_MODEL;
|
|
307
|
+
if (!spec)
|
|
308
|
+
return;
|
|
309
|
+
const separator = spec.indexOf('/');
|
|
310
|
+
if (separator < 1)
|
|
311
|
+
throw new Error('EXPLORBOT_DECISION_MODEL must be written as "provider/model-id", e.g. openrouter/typesafe/jev-1.13');
|
|
312
|
+
config.ai.decisionModel = { ...config.ai.decisionModel, provider: spec.slice(0, separator), model: spec.slice(separator + 1) };
|
|
313
|
+
}
|
|
303
314
|
async buildEnvConfig(baseUrl, outputRoot) {
|
|
304
315
|
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
305
316
|
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
|