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.
Files changed (42) hide show
  1. package/bin/explorbot-cli.ts +2 -1
  2. package/boat/prima/README.md +31 -4
  3. package/boat/prima/bin/prima-cli.ts +2 -1
  4. package/dist/bin/explorbot-cli.js +2 -1
  5. package/dist/boat/prima/bin/prima-cli.js +2 -1
  6. package/dist/package.json +2 -2
  7. package/dist/src/action.d.ts +1 -0
  8. package/dist/src/action.js +7 -0
  9. package/dist/src/ai/judge-provider.js +5 -0
  10. package/dist/src/ai/judge.d.ts +3 -1
  11. package/dist/src/ai/judge.js +29 -6
  12. package/dist/src/ai/pilot.d.ts +1 -0
  13. package/dist/src/ai/pilot.js +17 -4
  14. package/dist/src/ai/provider.js +15 -8
  15. package/dist/src/ai/rules.js +13 -0
  16. package/dist/src/ai/tester.d.ts +1 -0
  17. package/dist/src/ai/tester.js +11 -4
  18. package/dist/src/ai/tools.js +18 -9
  19. package/dist/src/commands/options/decision-model-option.d.ts +6 -0
  20. package/dist/src/commands/options/decision-model-option.js +10 -0
  21. package/dist/src/commands/options/index.d.ts +2 -0
  22. package/dist/src/commands/options/index.js +2 -0
  23. package/dist/src/config.d.ts +2 -0
  24. package/dist/src/config.js +11 -0
  25. package/docs/basics/providers.md +38 -0
  26. package/docs/index.json +1 -0
  27. package/docs/reference/commands.md +1 -0
  28. package/docs/reference/configuration.md +2 -0
  29. package/docs/web-testing/decisions.md +119 -0
  30. package/docs/workflow/agentic-usage.md +1 -0
  31. package/package.json +2 -2
  32. package/src/action.ts +8 -0
  33. package/src/ai/judge-provider.ts +6 -0
  34. package/src/ai/judge.ts +25 -6
  35. package/src/ai/pilot.ts +17 -4
  36. package/src/ai/provider.ts +14 -8
  37. package/src/ai/rules.ts +13 -0
  38. package/src/ai/tester.ts +11 -3
  39. package/src/ai/tools.ts +22 -11
  40. package/src/commands/options/decision-model-option.ts +11 -0
  41. package/src/commands/options/index.ts +2 -0
  42. package/src/config.ts +11 -1
@@ -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)}`);
@@ -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
- export PRIMA_CLI_AI_MODEL=openrouter/openai/gpt-oss-120b
66
- export OPENROUTER_API_KEY=your-key
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 the model name, so that is the whole setup. Fish uses `set -gx` instead of `export`. Since prima is usually run by a coding agent, the better home is the agent's own config — in `~/.claude/settings.json`, an `env` block reaches every command the agent runs:
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.0",
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-beta.3",
105
+ "codeceptjs": "^4.2.0",
106
106
  "commander": "^14.0.1",
107
107
  "debug": "^4.4.3",
108
108
  "dedent": "^1.6.0",
@@ -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;
@@ -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 {
@@ -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;
@@ -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
- constructor(provider, enabled) {
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
- return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false });
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()?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence }));
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
- const answer = await this.provider.decide(state, question, list).catch((error) => this.recordFailure(error));
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
- if (answer.probability <= APPROVAL_THRESHOLD)
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()?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
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
  }
@@ -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>>;
@@ -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}
@@ -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, cancel, controller, busy) {
54
+ function abortAfterIdle(ms, state, controller, busy) {
55
55
  return new Promise((_, reject) => {
56
56
  const tick = () => {
57
- if (cancel.cancelled)
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 cancel = { cancelled: false };
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, cancel, controller, busy)]);
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
- cancel.cancelled = true;
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',
@@ -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]
@@ -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>;
@@ -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
- const guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
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.consecutiveFailures >= 3)
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;
@@ -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, UNDECIDED } from "./judge.js";
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
- const success = await action.attempt(command, explanation);
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, judge, explanation);
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
- const labels = (matched || []).map((element) => `${element.text || 'no text'} — ${element.html}`);
1152
- const pick = await judge?.decide('Which listed element does the intent name?', [...labels, UNDECIDED], { intent });
1153
- if (!pick?.value)
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,6 @@
1
+ import { BaseOption } from './base-option.js';
2
+ export declare class DecisionModelOption extends BaseOption {
3
+ flags: string;
4
+ description: string;
5
+ apply(options: Record<string, any>): void;
6
+ }
@@ -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();
@@ -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>;
@@ -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;