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/docs/basics/providers.md
CHANGED
|
@@ -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.
|
|
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/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');
|
package/src/ai/judge-provider.ts
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
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,
|
|
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
|
-
|
|
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()
|
|
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(
|
package/src/ai/provider.ts
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
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();
|