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
@@ -22,6 +22,8 @@ Explorbot uses three roles:
22
22
 
23
23
  Pick a fast, cheap model for the first two and a stronger one for the third. When a provider has no recommended model for one of these roles, combine it with another provider for that role.
24
24
 
25
+ An optional [decision model](#decision-model) can answer narrow yes/no and pick-one questions so the other models are called less often.
26
+
25
27
  ### OpenRouter
26
28
 
27
29
  Start with OpenRouter. One key reaches [many providers and models](https://openrouter.ai/models).
@@ -308,6 +310,41 @@ Laguna XS is an agentic coding model — fast, cheap, and reliable at tool calli
308
310
 
309
311
  Keep `agenticModel` on another provider. Poolside's endpoint accepts `response_format: json_schema` but does not enforce it, so structured-output calls depend on the model volunteering valid JSON. Laguna XS usually does; Laguna S answers in prose instead, which makes it unusable for the Planner, Pilot, and Captain. Laguna S is also slow enough under page-sized prompts to hit Explorbot's request timeouts, so it is not a substitute for Laguna XS in the `model` role either.
310
312
 
313
+ ## Decision Model
314
+
315
+ A decision model is an optional fourth role. It does not generate text or call tools. It receives the current state and one closed question, and returns an answer with a probability. Explorbot uses it for narrow choices that would otherwise take a call to a larger model or a guess.
316
+
317
+ Explorbot supports TypeSafe's Jev, reached through OpenRouter or through TypeSafe's API directly:
318
+
319
+ ```javascript
320
+ export default {
321
+ ai: {
322
+ // ...your model, visionModel, agenticModel...
323
+ decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' },
324
+ // or: decisionModel: { provider: 'typesafe', model: 'jev-latest' },
325
+ },
326
+ };
327
+ ```
328
+
329
+ | Option | Type | Description |
330
+ |--------|------|-------------|
331
+ | `provider` | `'openrouter' \| 'typesafe'` | Authenticates with `OPENROUTER_API_KEY` or `TYPESAFE_API_KEY` |
332
+ | `model` | `string` | Model ID at that provider |
333
+ | `tool` | `boolean` | Give Tester and Pilot a `judge` tool to ask their own questions. Default: `true` |
334
+ | `direct` | `boolean` | Consult the model at Explorbot's built-in decision points. Default: `true` |
335
+ | `threshold` | `number` | How sure the model must be before Explorbot acts on an answer, between 0 and 1. Default: `0.7` |
336
+
337
+ Without a config file, or to turn it on for one run, set `EXPLORBOT_DECISION_MODEL` or pass `--decision-model` as `provider/model-id`. It overrides `provider` and `model` in the config and keeps `tool` and `direct`:
338
+
339
+ ```bash
340
+ EXPLORBOT_DECISION_MODEL=openrouter/typesafe/jev-1.13 npx explorbot explore
341
+ npx prima-cli go "Settings" --decision-model typesafe/jev-latest
342
+ ```
343
+
344
+ Leave `decisionModel` unset and Explorbot never calls it. An unknown provider, a missing API key, or a `threshold` outside 0 and 1 stops Explorbot at startup.
345
+
346
+ Explorbot acts on an answer only when it is more sure than `threshold` (70% by default), and otherwise does what it would have done without the decision model. See [Decisions](../web-testing/decisions.md) for where it is consulted, the `judge` tool, and what happens when an answer is rejected.
347
+
311
348
  ## Multi-Provider Configuration
312
349
 
313
350
  Mix clients the same way you assign `model`, `visionModel`, and `agenticModel`. Each field can use a different provider instance — a fast provider does the token-heavy reading while a stronger one makes the decisions:
@@ -359,4 +396,5 @@ export OPENAI_API_KEY=your-key-here
359
396
  export ANTHROPIC_API_KEY=your-key-here
360
397
  export GOOGLE_API_KEY=your-key-here
361
398
  export MISTRAL_API_KEY=your-key-here
399
+ export TYPESAFE_API_KEY=your-key-here
362
400
  ```
package/docs/index.json CHANGED
@@ -22,6 +22,7 @@
22
22
  { "title": "Planner", "file": "web-testing/planner.md", "description": "Tune what gets tested" },
23
23
  { "title": "Researcher", "file": "web-testing/researcher.md", "description": "How pages are analyzed, and how to tune it" },
24
24
  { "title": "Page interaction", "file": "web-testing/page-interaction.md", "description": "How agents read and act on a page" },
25
+ { "title": "Decisions", "file": "web-testing/decisions.md", "description": "The optional decision model: built-in decision points, the judge tool, fallbacks" },
25
26
  { "title": "Automated tests", "file": "web-testing/automated-tests.md", "description": "The runnable test files you keep" },
26
27
  { "title": "Rerun", "file": "web-testing/rerun.md", "description": "Re-run generated tests with AI healing" },
27
28
  { "title": "Agents", "file": "web-testing/agents.md", "description": "What each agent does under the hood" },
@@ -112,6 +112,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
112
112
  | `EXPLORBOT_URL` | Base URL to test; the API boat reads it as the base endpoint |
113
113
  | `EXPLORBOT_VISION_MODEL` | Screenshot analysis; overrides the provider recommendation |
114
114
  | `EXPLORBOT_AGENTIC_MODEL` | Captain and Pilot decisions; overrides the provider recommendation |
115
+ | `EXPLORBOT_DECISION_MODEL` | Turns on the decision model, as provider/model-id (openrouter/typesafe/jev-1.13 or typesafe/jev-latest); overrides ai.decisionModel |
115
116
  | `EXPLORBOT_OUTPUT` | Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites |
116
117
  | `EXPLORBOT_EPHEMERAL` | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
117
118
  | `EXPLORBOT_KNOWLEDGE` | Inline knowledge text, applied to every page |
@@ -537,6 +537,7 @@ export default {
537
537
  ai: {
538
538
  model: groq('openai/gpt-oss-20b'), // Default model instance (required)
539
539
  visionModel: groq('meta-llama/llama-4-scout-17b-16e-instruct'), // Model for screenshot analysis; setting it enables vision features
540
+ decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' }, // Optional; see providers docs
540
541
  config: {}, // Additional provider config
541
542
  langfuse: { // Observability settings
542
543
  enabled: true,
@@ -624,6 +625,7 @@ export default {
624
625
 
625
626
  - [API testing](../api-testing/basics.md) — API testing setup and commands
626
627
  - [AI providers](../basics/providers.md) — provider setup examples
628
+ - [Decisions](../web-testing/decisions.md) — the optional decision model and its fallbacks
627
629
  - [Agents](../web-testing/agents.md) — agent descriptions and workflows
628
630
  - [Agent hooks](../web-testing/hooks.md) — custom code before and after an agent runs
629
631
  - [Researcher agent](../web-testing/researcher.md) — Researcher configuration and usage
@@ -0,0 +1,119 @@
1
+ # Decisions
2
+
3
+ A decision model answers one closed question about the current state: a yes/no statement, or a pick from a list of options. It returns the answer together with its probability. Explorbot uses it to settle narrow choices quickly and cheaply that would otherwise need a call to a larger model or a guess.
4
+
5
+ The decision model is optional. Without it Explorbot behaves exactly as described elsewhere in these docs. Setup is covered in [AI providers](../basics/providers.md#decision-model).
6
+
7
+ ## Decision rules
8
+
9
+ Every question has one of two shapes:
10
+
11
+ | Shape | Asked as | Answer |
12
+ |---|---|---|
13
+ | **Statement** | "The page shows that this claim is true." | Probability that the statement holds |
14
+ | **Choice** | "Which listed element does the intent name?" plus numbered options | The chosen option and its probability |
15
+
16
+ A choice always includes an extra *undecided* option, so the model can say that none of the options fits. The question tells the model to choose it when it is not sure.
17
+
18
+ Explorbot acts on an answer only when it is **approved**:
19
+
20
+ - a statement's probability is above the threshold, or
21
+ - a choice picked a real option, not *undecided*, with a probability above the threshold.
22
+
23
+ The threshold is **70%** unless `decisionModel.threshold` sets another value.
24
+
25
+ Anything else counts as **rejected**, including:
26
+
27
+ - a confident "no", or any probability at or below the threshold
28
+ - *undecided*
29
+ - a choice offered with fewer than two options
30
+ - a request timeout (15 seconds) or a failed request
31
+ - `direct: false` in the config, for built-in decision points
32
+
33
+ A rejected answer never means "confidently no". It means Explorbot has no usable answer, so it takes its usual path. Every question is worded so that approving it lets Explorbot take a shortcut and rejecting it keeps the usual path. A wrong or unsure answer can therefore cost a shortcut, but never a check.
34
+
35
+ ## Built-in decision points
36
+
37
+ These are fixed places in the code where Explorbot asks the decision model before doing the usual, more expensive work. Setting `direct: false` turns them off.
38
+
39
+ ### Tester: locator matches several elements
40
+
41
+ When a click or fill fails because the locator matched more than one element, Explorbot lists the matches and asks which one the step meant.
42
+
43
+ - **Approved:** a click is repeated right away on the chosen element with `elementIndex`, before the Tester's remaining fallback locators. For other actions, the Tester is told which element to use.
44
+ - **Identical options:** when two matches look the same, the question is not asked. The Tester is told to click by appearance with `visualClick()`.
45
+ - **Fallback:** the Tester receives the numbered list of matches and picks one itself, narrows the locator, or uses a visual click.
46
+
47
+ ### Pilot: progress review
48
+
49
+ Every few steps, Pilot reviews the Tester's recent actions with the `agenticModel`. On a routine review it first asks whether the recent actions advance the scenario toward its remaining expected outcomes. The decision model sees the scenario, its planned steps, which expected outcomes are checked and which remain, the run log, the pages visited, the current page and the recent actions.
50
+
51
+ A review forced by a run of failed or empty steps skips the question and goes straight to Pilot.
52
+
53
+ - **Approved:** the review is skipped and the Tester continues. This saves one `agenticModel` call.
54
+ - **Fallback:** Pilot runs its full review as usual.
55
+
56
+ ### Navigator: claim already verified
57
+
58
+ Before Navigator writes assertions for a claim, it asks whether a claim it has already verified on this page means the same thing, even if it is worded differently.
59
+
60
+ - **Approved:** the claim counts as verified and no assertions are generated or run.
61
+ - **Fallback:** Navigator verifies the claim with assertions as usual.
62
+
63
+ The new claim is not added to the page's verified claims. Only a claim backed by assertions that actually ran is recorded as verified.
64
+
65
+ ### Navigator: claim no assertion can express
66
+
67
+ Some claims cannot be turned into an assertion. When that happens, Navigator asks whether the page shows the claim is true.
68
+
69
+ - **Approved:** `verify` reports that the page appears to confirm the claim. The result is marked as a judgement, kept apart from assertions that ran, and the Tester is asked to restate the claim so it can be asserted.
70
+ - **Fallback:** `verify` reports that no assertion could express the claim, and suggests restating it or checking it with `see`.
71
+
72
+ In both cases `verify` returns a failure, because nothing ran in the browser.
73
+
74
+ ### Prima: `go` to a described page
75
+
76
+ When `prima go` gets a description of a page instead of a URL, it asks which control on the current page leads there.
77
+
78
+ - **Approved:** Prima clicks that control. It then asks a second question: whether the new page shows the target. Only when that is approved too does Prima report success.
79
+ - **Fallback:** if the choice is rejected, or the click fails, or the page doesn't change, or arrival is not confirmed, Prima hands off to the Navigator as usual.
80
+
81
+ ### Prima: `check` expected outcomes
82
+
83
+ When `prima check` settles the expected outcomes of a finished run without a screenshot, it asks what the run established about each outcome: that it happened, or that it did not.
84
+
85
+ - **Approved:** that outcome is settled as passed or failed without calling the `agenticModel`.
86
+ - **Fallback:** the remaining outcomes are settled by the `agenticModel` as usual. When a screenshot is available, all outcomes go to the vision model and the decision model is not asked.
87
+
88
+ ## The `judge` tool
89
+
90
+ With `tool: true`, Tester and Pilot get a `judge` tool. They use it when a step depends on reading the page rather than running a command. The tool takes:
91
+
92
+ | Argument | Description |
93
+ |---|---|
94
+ | `question` | A statement to confirm, or the question a list of options answers |
95
+ | `options` | Possible answers. Leave it out to confirm a statement |
96
+ | `context` | Anything the page itself doesn't show |
97
+
98
+ The model supplies only the question. The tool gathers the rest of the state itself: the current scenario, the compact ARIA snapshot of the page (up to 12,000 characters), and the last eight test steps.
99
+
100
+ - **Approved:** the tool returns the answer and its confidence.
101
+ - **Rejected:** the tool returns "Not confirmed" and tells the agent to gather more context or try another route. "Not confirmed" means the page doesn't settle the question. It does not mean the statement is false.
102
+
103
+ `tool` and `direct` are independent. For example, `tool: false` keeps the built-in decision points but stops the agents from asking questions of their own.
104
+
105
+ ## Where it is not used
106
+
107
+ - **Pilot's verdict on a test.** Whether a scenario passed, failed or was impossible is always decided by the `agenticModel`.
108
+ - **Filtering experience.** One question per stored experience block, on every prompt, would cost more than it saves.
109
+ - **Generation, screenshots and tool calling.** The decision model reads text and answers questions. It does not write, look at images or act on the page.
110
+
111
+ ## Tracing
112
+
113
+ Each question is recorded as a `judge.decide` span:
114
+
115
+ - **Input:** the exact request body sent to the provider: model, state and question. Post it again to the endpoint to replay the decision.
116
+ - **Output:** the raw answer and its probability, before the threshold is applied.
117
+ - **Metadata:** `judgeQuestion` for filtering by question, `judgeDecision` with the approved value, confidence, threshold and model, and `judgeEndpoint`.
118
+
119
+ A failed request marks the span as an error and records the reason. The TUI shows `⚖️ Asking judge...` while a question is in flight. See [Observability](../contributing/observability.md) for setting up tracing.
@@ -52,6 +52,7 @@ No `init`, no config file, no project directory, no model IDs to look up. These
52
52
  | `EXPLORBOT_URL` | yes | Base URL to test; the API boat reads it as the base endpoint |
53
53
  | `EXPLORBOT_VISION_MODEL` | no | Screenshot analysis; overrides the provider recommendation |
54
54
  | `EXPLORBOT_AGENTIC_MODEL` | no | Captain and Pilot decisions; overrides the provider recommendation |
55
+ | `EXPLORBOT_DECISION_MODEL` | no | Turns on the decision model, as provider/model-id (openrouter/typesafe/jev-1.13 or typesafe/jev-latest); overrides ai.decisionModel |
55
56
  | `EXPLORBOT_OUTPUT` | no | Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites |
56
57
  | `EXPLORBOT_EPHEMERAL` | no | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
57
58
  | `EXPLORBOT_KNOWLEDGE` | no | Inline knowledge text, applied to every page |
package/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",
package/src/action.ts CHANGED
@@ -491,6 +491,14 @@ class Action {
491
491
  }
492
492
  }
493
493
 
494
+ public async attemptExactElementIndex(codeBlock: string, elementIndex: number, originalMessage?: string): Promise<boolean> {
495
+ const applyElementIndex = (step: any) => {
496
+ step.opts = { ...step.opts, elementIndex };
497
+ };
498
+ codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
499
+ return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
500
+ }
501
+
494
502
  async exitIframe(): Promise<void> {
495
503
  if (!this.playwrightHelper.frame) return;
496
504
  debugLog('Switching to main frame');
@@ -1,3 +1,6 @@
1
+ import { Observability } from '../observability.ts';
2
+ import { redactSecrets } from '../utils/secrets.ts';
3
+
1
4
  const REQUEST_TIMEOUT_MS = 15000;
2
5
 
3
6
  export class JudgeProvider {
@@ -21,6 +24,9 @@ export class JudgeProvider {
21
24
  let q: Record<string, unknown> = { type: 'noul', instructions: question };
22
25
  if (options) q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
23
26
  const body = JSON.stringify({ model: this.model, state, questions: { q } });
27
+ const span = Observability.getSpan();
28
+ span?.setAttribute('langfuse.observation.input', redactSecrets(body));
29
+ span?.setAttribute('ai.telemetry.metadata.judgeEndpoint', this.url);
24
30
  const controller = new AbortController();
25
31
  const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
26
32
  try {
package/src/ai/judge.ts CHANGED
@@ -9,6 +9,7 @@ const debugLog = createDebug('explorbot:judge');
9
9
  const APPROVAL_THRESHOLD = 0.7;
10
10
 
11
11
  export const UNDECIDED = 'undecided';
12
+ const UNDECIDED_HINT = `Choose "${UNDECIDED}" if you are not sure or no option fits.`;
12
13
  export const JUDGE_PAGE_CAP = 12000;
13
14
 
14
15
  export class Decision {
@@ -29,12 +30,15 @@ export class Decision {
29
30
  export class Judge {
30
31
  constructor(
31
32
  private provider: JudgeProvider,
32
- private enabled: { tool: boolean; direct: boolean }
33
+ private enabled: { tool: boolean; direct: boolean },
34
+ private threshold = APPROVAL_THRESHOLD
33
35
  ) {}
34
36
 
35
37
  static fromConfig(config: AIConfig['decisionModel']): Judge | null {
36
38
  if (!config) return null;
37
- return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false });
39
+ const threshold = config.threshold ?? APPROVAL_THRESHOLD;
40
+ if (!(threshold > 0 && threshold < 1)) throw new Error(`decisionModel.threshold must be between 0 and 1, got ${config.threshold}`);
41
+ return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false }, threshold);
38
42
  }
39
43
 
40
44
  get toolEnabled(): boolean {
@@ -46,12 +50,21 @@ export class Judge {
46
50
  return this.consult(question, options, state);
47
51
  }
48
52
 
53
+ async pick(question: string, options: string[], state: unknown): Promise<number | null> {
54
+ if (new Set(options).size < options.length) return null;
55
+ const decision = await this.decide(question, [...options, UNDECIDED], state);
56
+ if (!decision.value) return null;
57
+ return options.indexOf(decision.value) + 1;
58
+ }
59
+
49
60
  async consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
50
61
  if (Array.isArray(options) && options.length < 2) return new Decision(null, 0);
51
62
  return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
52
63
  setActivity('⚖️ Asking judge...', 'ai');
53
64
  const decision = await this.request(question, options, state).finally(() => clearActivity());
54
- Observability.getSpan()?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence }));
65
+ const span = Observability.getSpan();
66
+ span?.setAttribute('ai.telemetry.metadata.judgeQuestion', question);
67
+ span?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence, threshold: this.threshold, model: this.provider.model }));
55
68
  return decision;
56
69
  });
57
70
  }
@@ -59,17 +72,23 @@ export class Judge {
59
72
  private async request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
60
73
  let list: string[] | undefined;
61
74
  if (Array.isArray(options)) list = options;
75
+ let instructions = question;
76
+ if (list?.includes(UNDECIDED)) instructions = `${question} ${UNDECIDED_HINT}`;
62
77
 
63
- const answer = await this.provider.decide(state, question, list).catch((error: unknown) => this.recordFailure(error));
78
+ const answer = await this.provider.decide(state, instructions, list).catch((error: unknown) => this.recordFailure(error));
64
79
  if (!answer) return new Decision(null, 0);
65
- if (answer.probability <= APPROVAL_THRESHOLD) return new Decision(null, answer.probability);
80
+ Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
81
+ if (answer.probability <= this.threshold) return new Decision(null, answer.probability);
66
82
  if (answer.value === UNDECIDED) return new Decision(null, answer.probability);
67
83
  return new Decision(answer.value, answer.probability);
68
84
  }
69
85
 
70
86
  private recordFailure(error: unknown): null {
71
87
  debugLog('judge declined: %s', error);
72
- 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));
73
92
  return null;
74
93
  }
75
94
  }
package/src/ai/pilot.ts CHANGED
@@ -19,7 +19,7 @@ import type { Agent, AgentDeps } from './agent.ts';
19
19
  import type { Conversation } from './conversation.ts';
20
20
  import type { Fisherman } from './fisherman.ts';
21
21
  import { createAskApiTool } from './fisherman/tools.ts';
22
- import { type Judge, UNDECIDED } from './judge.ts';
22
+ import { JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
23
23
  import type { Navigator } from './navigator.ts';
24
24
  import type { Provider } from './provider.ts';
25
25
  import type { Researcher } from './researcher.ts';
@@ -542,6 +542,22 @@ export class Pilot implements Agent {
542
542
  );
543
543
  }
544
544
 
545
+ async periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string> {
546
+ const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
547
+ const healthy = await this.judge?.decide('The recent actions advance the scenario toward its remaining expected outcomes.', null, {
548
+ scenario: task.scenario,
549
+ plannedSteps: task.plannedSteps,
550
+ expectations: this.formatExpectations(task),
551
+ runLog: task.notesToString() || 'No steps recorded.',
552
+ visitedUrls: task.getVisitedUrls({ localOnly: true }),
553
+ state: this.buildStateContext(currentState),
554
+ page: currentState.getCompactARIA().slice(0, JUDGE_PAGE_CAP),
555
+ recentActions: this.formatActions(toolCalls),
556
+ });
557
+ if (healthy?.approved) return '';
558
+ return this.analyzeProgress(task, currentState, testerConversation);
559
+ }
560
+
545
561
  async analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string> {
546
562
  tag('substep').log('Pilot analyzing progress...');
547
563
 
@@ -555,9 +571,6 @@ export class Pilot implements Agent {
555
571
  const actionsContext = this.formatActions(toolCalls);
556
572
  const stateContext = this.buildStateContext(currentState);
557
573
 
558
- 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 });
559
- if (healthy?.approved) return '';
560
-
561
574
  const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
562
575
 
563
576
  const text = await this.sendToPilot(
@@ -1,10 +1,10 @@
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';
7
6
  import type { ModelMessage } from 'ai';
7
+ import dedent from 'dedent';
8
8
  import { z } from 'zod';
9
9
  import { clearActivity, setActivity } from '../activity.ts';
10
10
  import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
@@ -60,14 +60,15 @@ function extractCachedTokens(usage: any): number {
60
60
  return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
61
61
  }
62
62
 
63
- function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController, busy: { tools: number }): Promise<never> {
63
+ function abortAfterIdle(ms: number, state: { cancelled: boolean; timedOut: boolean }, controller: AbortController, busy: { tools: number }): Promise<never> {
64
64
  return new Promise((_, reject) => {
65
65
  const tick = () => {
66
- if (cancel.cancelled) return;
66
+ if (state.cancelled) return;
67
67
  if (executionController.isAwaitingInput() || busy.tools > 0) {
68
68
  setTimeout(tick, ms);
69
69
  return;
70
70
  }
71
+ state.timedOut = true;
71
72
  controller.abort();
72
73
  reject(new Error('AI request timeout'));
73
74
  };
@@ -252,13 +253,18 @@ export class Provider {
252
253
  }
253
254
 
254
255
  private async raceWithIdleTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, timeoutMs: number, busy: { tools: number } = { tools: 0 }): Promise<T> {
255
- const cancel = { cancelled: false };
256
+ const state = { cancelled: false, timedOut: false };
256
257
  const controller = new AbortController();
257
258
  const combinedSignal = combinedAbortSignal(controller);
258
259
  try {
259
- return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, cancel, controller, busy)]);
260
+ return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, state, controller, busy)]);
261
+ } catch (error) {
262
+ if (state.timedOut && !executionController.getAbortSignal()?.aborted) {
263
+ throw new Error('AI request timeout', { cause: error });
264
+ }
265
+ throw error;
260
266
  } finally {
261
- cancel.cancelled = true;
267
+ state.cancelled = true;
262
268
  }
263
269
  }
264
270
 
@@ -683,7 +689,7 @@ export class Provider {
683
689
  throw new Error(`Vision model not configured. Set ai.visionModel in your config; ${getCliName()} recommended-models lists the providers that serve one`);
684
690
  }
685
691
 
686
- setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
692
+ setActivity(`🤖 Processing image with ${getModelName(this.config.visionModel)}`, 'ai');
687
693
 
688
694
  const messages: ModelMessage[] = [
689
695
  {
package/src/ai/rules.ts CHANGED
@@ -497,6 +497,19 @@ export const actionRule = dedent`
497
497
 
498
498
  After hovering, use see() or context() to check what appeared.
499
499
 
500
+ ### I.seeInClipboard / I.seeClipboardEquals / I.clearClipboard
501
+
502
+ Check what a copy control put into the system clipboard. Run via form(), not click().
503
+ I.seeInClipboard asserts the clipboard contains the text, I.seeClipboardEquals asserts an exact match,
504
+ I.clearClipboard empties it — call it before copying so a stale value can't pass the check.
505
+
506
+ <example>
507
+ I.clearClipboard();
508
+ I.click({ role: 'button', text: 'Copy link' });
509
+ I.seeInClipboard('/invite/');
510
+ I.seeClipboardEquals('ABC-123');
511
+ </example>
512
+
500
513
  [DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
501
514
  [INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML]
502
515
  [DO NOT USE WAIT FUNCTIONS]
package/src/ai/tester.ts CHANGED
@@ -324,7 +324,12 @@ export class Tester extends TaskAgent implements Agent {
324
324
  const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
325
325
  if (guidance) nextStep += `\n\n${guidance}`;
326
326
  } else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
327
- const guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
327
+ let guidance: string;
328
+ if (this.isStruggling) {
329
+ guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
330
+ } else {
331
+ guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
332
+ }
328
333
  if (guidance) nextStep += `\n\n${guidance}`;
329
334
  this.consecutiveFailures = 0;
330
335
  this.lastAnalyzedStateHash = currentState.hash;
@@ -486,13 +491,16 @@ export class Tester extends TaskAgent implements Agent {
486
491
  this.regionTransitioned = false;
487
492
  return true;
488
493
  }
489
- if (this.consecutiveFailures >= 3) return true;
490
- if (this.consecutiveEmptyResults >= 2) return true;
494
+ if (this.isStruggling) return true;
491
495
  if (iteration % this.progressCheckInterval !== 0) return false;
492
496
  if (this.lastAnalyzedStateHash === currentState.hash) return false;
493
497
  return true;
494
498
  }
495
499
 
500
+ private get isStruggling(): boolean {
501
+ return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
502
+ }
503
+
496
504
  private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
497
505
  if (task.hasFinished) return false;
498
506
 
package/src/ai/tools.ts CHANGED
@@ -16,7 +16,7 @@ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/str
16
16
  import { WebElement } from '../utils/web-element.ts';
17
17
  import type { ToolDeps } from './agent.ts';
18
18
  import { createJudgeTool } from './judge-tool.ts';
19
- import { JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
19
+ import { JUDGE_PAGE_CAP, type Judge } from './judge.ts';
20
20
  import { Navigator } from './navigator.ts';
21
21
  import { Researcher } from './researcher.ts';
22
22
  import { sectionContextRule } from './rules.ts';
@@ -113,13 +113,20 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
113
113
 
114
114
  for (let i = 0; i < commands.length; i++) {
115
115
  const command = transformContainsCommand(commands[i]);
116
- const success = await action.attempt(command, explanation);
116
+ let success = await action.attempt(command, explanation);
117
117
 
118
118
  const attempt: { command: string; success: boolean; error?: string } = { command, success };
119
119
  if (action.lastError) attempt.error = errorText(action.lastError);
120
120
  attempts.push(attempt);
121
121
 
122
- if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
122
+ if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') {
123
+ ambiguityError = action.lastError;
124
+ if (judge) {
125
+ const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
126
+ const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
127
+ if (index) success = await action.attemptExactElementIndex(command, index, explanation);
128
+ }
129
+ }
123
130
 
124
131
  if (success) {
125
132
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
@@ -158,9 +165,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
158
165
  attempts,
159
166
  suggestion,
160
167
  },
161
- ambiguityError || action.lastError,
162
- judge,
163
- explanation
168
+ ambiguityError || action.lastError
164
169
  );
165
170
  },
166
171
  }),
@@ -1309,10 +1314,13 @@ export async function failedToolResult(action: string, message: string, data?: R
1309
1314
  result.suggestion = getMultipleElementsSuggestion();
1310
1315
  result.multipleElementsDetected = true;
1311
1316
  result.elements = formatElementList(matched);
1312
- const labels = (matched || []).map((element) => `${element.text || 'no text'} — ${element.html}`);
1313
- const pick = await judge?.decide('Which listed element does the intent name?', [...labels, UNDECIDED], { intent });
1314
- if (!pick?.value) return result;
1315
- const index = labels.indexOf(pick.value) + 1;
1317
+ if (!judge || !matched) return result;
1318
+ const index = await judge.pick(
1319
+ PICK_ELEMENT_QUESTION,
1320
+ matched.map((el) => el.label),
1321
+ { intent }
1322
+ );
1323
+ if (!index) return result;
1316
1324
  result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
1317
1325
  return result;
1318
1326
  }
@@ -1333,6 +1341,7 @@ function getMultipleElementsSuggestion(): string {
1333
1341
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1334
1342
  A match reported as not visible can never be acted on — pick one that is.
1335
1343
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1344
+ If the matches cannot be told apart, click the one you mean by appearance with visualClick().
1336
1345
  If the list is missing, call xpathCheck() to see what the locator matches.
1337
1346
  `;
1338
1347
  }
@@ -1379,6 +1388,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
1379
1388
  const MAX_DISAMBIGUATE_TEXT = 80;
1380
1389
  const MAX_DISAMBIGUATE_HTML = 300;
1381
1390
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1391
+ const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
1382
1392
 
1383
1393
  async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
1384
1394
  if (!error || error.name !== 'MultipleElementsFound') return null;
@@ -1393,7 +1403,7 @@ async function extractWebElements(error: Error | null | undefined): Promise<Matc
1393
1403
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1394
1404
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1395
1405
  const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1396
- result.push({ xpath, html, text, visible });
1406
+ result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
1397
1407
  } catch (e) {
1398
1408
  debugLog('Failed to get details for element %d: %s', i, e);
1399
1409
  }
@@ -1443,5 +1453,6 @@ interface MatchedElement {
1443
1453
  xpath: string;
1444
1454
  html: string;
1445
1455
  text: string;
1456
+ label: string;
1446
1457
  visible?: boolean;
1447
1458
  }
@@ -0,0 +1,11 @@
1
+ import { BaseOption } from './base-option.js';
2
+
3
+ export class DecisionModelOption extends BaseOption {
4
+ flags = '--decision-model <model>';
5
+ description = 'Turn on the decision model for this run, as provider/model-id (e.g. openrouter/typesafe/jev-1.13)';
6
+
7
+ protected apply(options: Record<string, any>): void {
8
+ if (!options.decisionModel) return;
9
+ process.env.EXPLORBOT_DECISION_MODEL = options.decisionModel;
10
+ }
11
+ }
@@ -1,7 +1,9 @@
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
 
4
5
  export { BaseOption } from './base-option.js';
5
6
 
7
+ export const decisionModelOption = new DecisionModelOption();
6
8
  export const knowledgeOption = new KnowledgeOption();
7
9
  export const wsOption = new WsOption();