explorbot 0.4.10 → 0.5.0

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 (128) hide show
  1. package/README.md +4 -1
  2. package/bin/mdq.ts +18 -0
  3. package/boat/api-tester/src/ai/chief.ts +72 -0
  4. package/boat/api-tester/src/api-client.ts +37 -0
  5. package/boat/prima/src/prima.ts +41 -2
  6. package/dist/bin/mdq.js +19 -0
  7. package/dist/boat/api-tester/src/ai/chief.js +69 -0
  8. package/dist/boat/api-tester/src/api-client.js +26 -0
  9. package/dist/boat/prima/src/prima.js +42 -2
  10. package/dist/package.json +3 -2
  11. package/dist/src/ai/agent.d.ts +3 -1
  12. package/dist/src/ai/judge-provider.d.ts +17 -0
  13. package/dist/src/ai/judge-provider.js +56 -0
  14. package/dist/src/ai/judge-tool.d.ts +2 -0
  15. package/dist/src/ai/judge-tool.js +33 -0
  16. package/dist/src/ai/judge.d.ts +28 -0
  17. package/dist/src/ai/judge.js +71 -0
  18. package/dist/src/ai/navigator.d.ts +3 -0
  19. package/dist/src/ai/navigator.js +13 -4
  20. package/dist/src/ai/pilot.d.ts +4 -0
  21. package/dist/src/ai/pilot.js +57 -5
  22. package/dist/src/ai/planner.js +9 -6
  23. package/dist/src/ai/provider.d.ts +4 -1
  24. package/dist/src/ai/provider.js +52 -7
  25. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  26. package/dist/src/ai/researcher/locators.js +2 -2
  27. package/dist/src/ai/researcher/pagination.js +1 -1
  28. package/dist/src/ai/researcher/research-result.js +2 -2
  29. package/dist/src/ai/researcher.js +1 -1
  30. package/dist/src/ai/task-agent.d.ts +2 -0
  31. package/dist/src/ai/task-agent.js +3 -1
  32. package/dist/src/ai/tester.js +19 -15
  33. package/dist/src/ai/tools.d.ts +4 -3
  34. package/dist/src/ai/tools.js +35 -7
  35. package/dist/src/api/request-result.js +2 -1
  36. package/dist/src/command-handler.d.ts +1 -0
  37. package/dist/src/command-handler.js +24 -3
  38. package/dist/src/commands/base-command.d.ts +5 -0
  39. package/dist/src/commands/base-command.js +3 -0
  40. package/dist/src/commands/explore-command.d.ts +2 -1
  41. package/dist/src/commands/explore-command.js +12 -1
  42. package/dist/src/commands/freesail-command.js +8 -2
  43. package/dist/src/commands/init-command.js +1 -1
  44. package/dist/src/commands/navigate-command.d.ts +2 -1
  45. package/dist/src/commands/navigate-command.js +6 -0
  46. package/dist/src/commands/plan-load-command.d.ts +2 -1
  47. package/dist/src/commands/plan-load-command.js +4 -0
  48. package/dist/src/commands/plans-command.d.ts +3 -9
  49. package/dist/src/commands/plans-command.js +11 -21
  50. package/dist/src/commands/rerun-command.d.ts +2 -1
  51. package/dist/src/commands/rerun-command.js +5 -1
  52. package/dist/src/commands/research-command.d.ts +2 -1
  53. package/dist/src/commands/research-command.js +6 -0
  54. package/dist/src/commands/test-command.d.ts +2 -1
  55. package/dist/src/commands/test-command.js +4 -1
  56. package/dist/src/components/Autocomplete.js +26 -12
  57. package/dist/src/components/InputReadline.js +10 -1
  58. package/dist/src/config.d.ts +6 -0
  59. package/dist/src/experience-tracker.js +4 -3
  60. package/dist/src/explorbot.d.ts +3 -0
  61. package/dist/src/explorbot.js +8 -0
  62. package/dist/src/knowledge-tracker.js +1 -1
  63. package/dist/src/state-manager.d.ts +2 -0
  64. package/dist/src/state-manager.js +16 -0
  65. package/dist/src/test-plan.d.ts +11 -0
  66. package/dist/src/test-plan.js +54 -2
  67. package/dist/src/utils/aria-ref.js +1 -1
  68. package/dist/src/utils/logger.js +8 -2
  69. package/dist/src/utils/markdown-query.d.ts +1 -48
  70. package/dist/src/utils/markdown-query.js +1 -444
  71. package/dist/src/utils/mdq/cli.d.ts +6 -0
  72. package/dist/src/utils/mdq/cli.js +122 -0
  73. package/dist/src/utils/mdq/edit.d.ts +24 -0
  74. package/dist/src/utils/mdq/edit.js +147 -0
  75. package/dist/src/utils/mdq/query.d.ts +118 -0
  76. package/dist/src/utils/mdq/query.js +451 -0
  77. package/dist/src/utils/strings.d.ts +1 -0
  78. package/dist/src/utils/strings.js +7 -0
  79. package/dist/src/utils/test-files.d.ts +1 -0
  80. package/dist/src/utils/test-files.js +5 -2
  81. package/docs/api-testing/planning.md +1 -1
  82. package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
  83. package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
  84. package/package.json +3 -2
  85. package/src/ai/agent.ts +3 -1
  86. package/src/ai/judge-provider.ts +62 -0
  87. package/src/ai/judge-tool.ts +35 -0
  88. package/src/ai/judge.ts +75 -0
  89. package/src/ai/navigator.ts +15 -4
  90. package/src/ai/pilot.ts +58 -5
  91. package/src/ai/planner.ts +9 -6
  92. package/src/ai/provider.ts +51 -7
  93. package/src/ai/researcher/deep-analysis.ts +2 -2
  94. package/src/ai/researcher/locators.ts +2 -2
  95. package/src/ai/researcher/pagination.ts +1 -1
  96. package/src/ai/researcher/research-result.ts +2 -2
  97. package/src/ai/researcher.ts +1 -1
  98. package/src/ai/task-agent.ts +4 -1
  99. package/src/ai/tester.ts +19 -16
  100. package/src/ai/tools.ts +42 -7
  101. package/src/api/request-result.ts +2 -1
  102. package/src/command-handler.ts +28 -3
  103. package/src/commands/base-command.ts +9 -0
  104. package/src/commands/explore-command.ts +15 -2
  105. package/src/commands/freesail-command.ts +9 -2
  106. package/src/commands/init-command.ts +1 -1
  107. package/src/commands/navigate-command.ts +8 -1
  108. package/src/commands/plan-load-command.ts +6 -1
  109. package/src/commands/plans-command.ts +13 -29
  110. package/src/commands/rerun-command.ts +7 -2
  111. package/src/commands/research-command.ts +8 -1
  112. package/src/commands/test-command.ts +6 -2
  113. package/src/components/Autocomplete.tsx +39 -10
  114. package/src/components/InputReadline.tsx +10 -1
  115. package/src/config.ts +1 -0
  116. package/src/experience-tracker.ts +4 -3
  117. package/src/explorbot.ts +8 -0
  118. package/src/knowledge-tracker.ts +1 -1
  119. package/src/state-manager.ts +16 -0
  120. package/src/test-plan.ts +67 -2
  121. package/src/utils/aria-ref.ts +1 -1
  122. package/src/utils/logger.ts +6 -1
  123. package/src/utils/markdown-query.ts +1 -519
  124. package/src/utils/mdq/cli.ts +118 -0
  125. package/src/utils/mdq/edit.ts +158 -0
  126. package/src/utils/mdq/query.ts +556 -0
  127. package/src/utils/strings.ts +7 -0
  128. package/src/utils/test-files.ts +5 -2
@@ -0,0 +1,79 @@
1
+ # Judge — a decision model tier — Design
2
+
3
+ An optional *decision model* answers one narrow question with a probability over the answers offered. Explorbot uses it where it would otherwise guess: a code call site asks, and a confident answer lets the site skip its own, more expensive decision. The model is TypeSafe's Jev (a "System One" model), reached through OpenRouter or TypeSafe's own API.
4
+
5
+ With `ai.decisionModel` unset, nothing registers and nothing calls out.
6
+
7
+ ## The interface
8
+
9
+ ```ts
10
+ judge.decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>
11
+
12
+ class Decision {
13
+ readonly value: string | null; // the winning option; 'yes' for an approved yes/no
14
+ readonly confidence: number; // P(yes) for a yes/no, the chosen option's probability for a list
15
+ get approved(): boolean; // value !== null
16
+ get rejected(): boolean; // value === null
17
+ }
18
+ ```
19
+
20
+ - **An array is a categorization**, sent as a Choice. `value` is the chosen option. Including `UNDECIDED` lets the model say none fits.
21
+ - **A boolean or `null` is a yes/no**, sent as a Noul, whose single number is P(yes). Only a confident yes approves.
22
+ - **Approved means the answer's probability is above 70%** and it isn't `UNDECIDED`.
23
+ - **Everything else is rejected**: a confident no, a low probability, `UNDECIDED`, a timeout, a failed request, a list with fewer than two options, or the direct path being disabled. `decide` never throws and never returns `null`.
24
+
25
+ The threshold and every failure mode live in one place. A call site only ever sees a confident decision or a rejection, so it cannot misread uncertainty.
26
+
27
+ ### The one rule for call sites
28
+
29
+ **`rejected` means "not approved", never "confidently no".** It absorbs uncertainty, so a question must be phrased so that `approved` is the action the site would take, and `rejected` falls through to today's behaviour. Then `if (decision?.approved)` is correct by construction.
30
+
31
+ `consult(...)` is the same call without the direct-path gate, used by the tool.
32
+
33
+ ## Configuration
34
+
35
+ ```js
36
+ ai: { decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' } } // via OpenRouter
37
+ ai: { decisionModel: { provider: 'typesafe', model: 'jev-latest' } } // TypeSafe API directly
38
+ ai: { decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13', tool: false } } // direct sites only
39
+ ```
40
+
41
+ `config.ts` holds only the field type. An unknown provider or a missing API key throws at startup, naming what to fix.
42
+
43
+ Everything transport-specific lives in `src/ai/judge-provider.ts`: `endpointFor()` (a `switch` over `openrouter` and `typesafe`, each with its endpoint and API-key variable), the HTTP call, timeout, and the Noul/Choice wire format. That file is temporary: when the Vercel AI SDK supports decision models it is deleted and `decisionModel` becomes a regular provider-built model.
44
+
45
+ `Judge.fromConfig()` builds the judge; it reaches agents through `AgentDeps` and tools through `ToolDeps`.
46
+
47
+ ## Where it is used
48
+
49
+ | Site | Question | Approved means |
50
+ |---|---|---|
51
+ | `failedToolResult`, multi-element branch | Which listed element does the intent name? | suggest that element by `elementIndex` |
52
+ | `Pilot.analyzeProgress` | The run is moving toward the goal and can continue without a supervisor now. | skip the review silently |
53
+ | `Pilot.settleExpectations`, text-only path | What did this run establish about the expected outcome? | settle it; the rest go to the agentic model |
54
+ | `Navigator.verifyState`, before the prompt | Which already verified claim means the same as this one? | treat it as verified, skip the HTML-bearing prompt |
55
+ | `Navigator.verifyState`, inexpressible branch | The page shows that this claim is true. | report it as a judgement, not an assertion |
56
+ | Prima `go()`, semantic target | Which listed control leads to the target? / The page now shows the target. | click the ref, confirm arrival, skip the navigator |
57
+ | `judge` tool (Tester and Pilot) | whatever the model asks | the answer; otherwise "not confirmed" |
58
+
59
+ Invariants the sites keep:
60
+
61
+ - **A judge answer never enters `verifications`.** A dedup match skips the work but writes no cache entry for the new claim, because a cache entry stands in for a proof.
62
+ - **Prima confirms arrival** before returning a success envelope. `cli.ts` exits on `envelope.ok`.
63
+ - **The tool assembles its own state**: scenario, compact ARIA capped at `JUDGE_PAGE_CAP`, and recent steps. The model supplies only the question.
64
+
65
+ ## Deliberately not used
66
+
67
+ - **Pilot's verdict.** A generic "the app never held the state the scenario assumes" would fire on boundary give-ups, like "previous page" on page 1 when `»` is listed, and turn an executable scenario into a clean-looking skip. That hides the execution gap. The fix for a boundary give-up belongs in planning, not the verdict.
68
+ - **Experience filtering.** It removes no model call, and one question per stored block on every prompt build costs more than it saves.
69
+ - **Generation, screenshots, tool-calling loops.** The model is text-only and does not generate.
70
+
71
+ ## Measurements behind the threshold
72
+
73
+ On states reconstructed from recorded traces:
74
+
75
+ - A literal, concrete phrasing beat an abstract one on identical state: 0.88 versus 0.58. Questions should name what they expect to see.
76
+ - A composite "continue or call Pilot" Choice tied at confidence 0.08 on a run that was progressing. Under `rejected` semantics, a tie falls through to a review, which is the safe direction.
77
+ - The first live probe picked `copy` over `root` at 0.48 versus 0.41 on a dialog whose Copy button was disabled. A 70% bar rejects that, correctly.
78
+
79
+ Threshold and phrasing should be tuned from traces: every call records `question`, `value` and `confidence` on its `judge.decide` span, and failures record their reason.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.10",
3
+ "version": "0.5.0",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -60,7 +60,8 @@
60
60
  "check": "biome check .",
61
61
  "check:fix": "biome check --write .",
62
62
  "langfuse:export": "bun run .claude/skills/explorbot-debug/langfuse-export.ts",
63
- "build:prima": "bun run scripts/build-prima-npm.ts"
63
+ "build:prima": "bun run scripts/build-prima-npm.ts",
64
+ "build:mdq": "bun run scripts/build-mdq-npm.ts"
64
65
  },
65
66
  "keywords": [
66
67
  "cli",
package/src/ai/agent.ts CHANGED
@@ -4,6 +4,7 @@ import type Explorer from '../explorer.ts';
4
4
  import type { KnowledgeTracker } from '../knowledge-tracker.ts';
5
5
  import type { PlaywrightRecorder } from '../playwright-recorder.ts';
6
6
  import type { StateManager } from '../state-manager.ts';
7
+ import type { Judge } from './judge.ts';
7
8
  import type { AIProvider } from './provider.ts';
8
9
 
9
10
  export interface Agent {
@@ -18,6 +19,7 @@ export interface AgentDeps {
18
19
  knowledgeTracker: KnowledgeTracker;
19
20
  requestStore: RequestStore;
20
21
  playwrightRecorder: PlaywrightRecorder;
22
+ judge?: Judge;
21
23
  }
22
24
 
23
- export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai'>;
25
+ export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai' | 'judge'>;
@@ -0,0 +1,62 @@
1
+ const REQUEST_TIMEOUT_MS = 15000;
2
+
3
+ export class JudgeProvider {
4
+ private fetchImpl: typeof fetch = fetch;
5
+ private requestTimeoutMs = REQUEST_TIMEOUT_MS;
6
+ private url: string;
7
+ private apiKey: string;
8
+
9
+ constructor(
10
+ provider: string,
11
+ readonly model: string
12
+ ) {
13
+ const { url, keyName } = JudgeProvider.endpointFor(provider);
14
+ const apiKey = process.env[keyName];
15
+ if (!apiKey) throw new Error(`Set ${keyName} to use the decision model`);
16
+ this.url = url;
17
+ this.apiKey = apiKey;
18
+ }
19
+
20
+ async decide(state: unknown, question: string, options?: string[]): Promise<ProviderDecision> {
21
+ let q: Record<string, unknown> = { type: 'noul', instructions: question };
22
+ if (options) q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
23
+ const body = JSON.stringify({ model: this.model, state, questions: { q } });
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
26
+ try {
27
+ const response = await this.fetchImpl(this.url, {
28
+ method: 'POST',
29
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
30
+ body,
31
+ signal: controller.signal,
32
+ });
33
+ if (!response.ok) throw new Error(`http_${response.status}`);
34
+
35
+ const answer = (await response.json())?.answers?.q;
36
+ if (!options && typeof answer?.noul === 'number') return { value: 'yes', probability: answer.noul };
37
+
38
+ const value = options?.[Number(answer?.choice)];
39
+ const probability = answer?.probabilities?.[answer?.choice];
40
+ if (value === undefined || typeof probability !== 'number') throw new Error('malformed_body');
41
+ return { value, probability };
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+
47
+ private static endpointFor(provider: string): { url: string; keyName: string } {
48
+ switch (provider) {
49
+ case 'openrouter':
50
+ return { url: 'https://openrouter.ai/api/alpha/decisions', keyName: 'OPENROUTER_API_KEY' };
51
+ case 'typesafe':
52
+ return { url: 'https://api.typesafe.ai/v1/systemone', keyName: 'TYPESAFE_API_KEY' };
53
+ default:
54
+ throw new Error(`Unknown decision model provider "${provider}" — use "openrouter" or "typesafe"`);
55
+ }
56
+ }
57
+ }
58
+
59
+ export interface ProviderDecision {
60
+ value: string;
61
+ probability: number;
62
+ }
@@ -0,0 +1,35 @@
1
+ import { tool } from 'ai';
2
+ import dedent from 'dedent';
3
+ import { z } from 'zod';
4
+ import type { ToolDeps } from './agent.ts';
5
+ import { failedToolResult, successToolResult } from './tools.ts';
6
+
7
+ export function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record<string, unknown>>): Record<string, any> {
8
+ const judge = deps.judge;
9
+ if (!judge?.toolEnabled) return {};
10
+
11
+ return {
12
+ judge: tool({
13
+ description: dedent`
14
+ Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
15
+ `,
16
+ inputSchema: z.object({
17
+ question: z.string().describe('The statement to confirm, or the question the options answer'),
18
+ options: z.array(z.string()).optional().describe('Possible answers. Omit to confirm a statement'),
19
+ context: z.string().optional().describe('Anything the page observation does not already carry'),
20
+ }),
21
+ execute: async ({ question, options, context }) => {
22
+ const state = await buildState();
23
+ if (context) state.context = context;
24
+
25
+ const decision = await judge.consult(question, options ?? null, state);
26
+ if (decision.rejected) {
27
+ return failedToolResult('judge', `Not confirmed: ${question}`, {
28
+ suggestion: 'The page does not settle this. Gather more context or take another route; do not assume either answer.',
29
+ });
30
+ }
31
+ return successToolResult('judge', { question, answer: decision.value, confidence: decision.confidence });
32
+ },
33
+ }),
34
+ };
35
+ }
@@ -0,0 +1,75 @@
1
+ import { clearActivity, setActivity } from '../activity.ts';
2
+ import type { AIConfig } from '../config.ts';
3
+ import { Observability } from '../observability.ts';
4
+ import { createDebug } from '../utils/logger.ts';
5
+ import { JudgeProvider } from './judge-provider.ts';
6
+
7
+ const debugLog = createDebug('explorbot:judge');
8
+
9
+ const APPROVAL_THRESHOLD = 0.7;
10
+
11
+ export const UNDECIDED = 'undecided';
12
+ export const JUDGE_PAGE_CAP = 12000;
13
+
14
+ export class Decision {
15
+ constructor(
16
+ readonly value: string | null,
17
+ readonly confidence: number
18
+ ) {}
19
+
20
+ get approved(): boolean {
21
+ return this.value !== null;
22
+ }
23
+
24
+ get rejected(): boolean {
25
+ return this.value === null;
26
+ }
27
+ }
28
+
29
+ export class Judge {
30
+ constructor(
31
+ private provider: JudgeProvider,
32
+ private enabled: { tool: boolean; direct: boolean }
33
+ ) {}
34
+
35
+ static fromConfig(config: AIConfig['decisionModel']): Judge | null {
36
+ if (!config) return null;
37
+ return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false });
38
+ }
39
+
40
+ get toolEnabled(): boolean {
41
+ return this.enabled.tool;
42
+ }
43
+
44
+ async decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
45
+ if (!this.enabled.direct) return new Decision(null, 0);
46
+ return this.consult(question, options, state);
47
+ }
48
+
49
+ async consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
50
+ if (Array.isArray(options) && options.length < 2) return new Decision(null, 0);
51
+ return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
52
+ setActivity('⚖️ Asking judge...', 'ai');
53
+ 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 }));
55
+ return decision;
56
+ });
57
+ }
58
+
59
+ private async request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
60
+ let list: string[] | undefined;
61
+ if (Array.isArray(options)) list = options;
62
+
63
+ const answer = await this.provider.decide(state, question, list).catch((error: unknown) => this.recordFailure(error));
64
+ if (!answer) return new Decision(null, 0);
65
+ if (answer.probability <= APPROVAL_THRESHOLD) return new Decision(null, answer.probability);
66
+ if (answer.value === UNDECIDED) return new Decision(null, answer.probability);
67
+ return new Decision(answer.value, answer.probability);
68
+ }
69
+
70
+ private recordFailure(error: unknown): null {
71
+ debugLog('judge declined: %s', error);
72
+ Observability.getSpan()?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
73
+ return null;
74
+ }
75
+ }
@@ -21,6 +21,7 @@ import { normalizeInlineText } from '../utils/strings.ts';
21
21
  import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
22
22
  import type { Agent, AgentDeps } from './agent.js';
23
23
  import type { Conversation } from './conversation.js';
24
+ import { type Decision, JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
24
25
  import type { Provider } from './provider.js';
25
26
  import { Researcher } from './researcher.ts';
26
27
  import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
@@ -80,6 +81,7 @@ class Navigator implements Agent {
80
81
  private explorer: Explorer;
81
82
  private config: ExplorbotConfig;
82
83
  private stateManager: StateManager;
84
+ private judge?: Judge;
83
85
 
84
86
  constructor(deps: AgentDeps) {
85
87
  this.provider = deps.ai;
@@ -89,6 +91,7 @@ class Navigator implements Agent {
89
91
  this.knowledgeTracker = deps.knowledgeTracker;
90
92
  this.experienceTracker = deps.stateManager.getExperienceTracker();
91
93
  this.hooksRunner = new HooksRunner(deps.explorer, deps.config);
94
+ this.judge = deps.judge;
92
95
  }
93
96
 
94
97
  private get verifyAttempts(): number {
@@ -570,7 +573,7 @@ class Navigator implements Agent {
570
573
  const countVisit = (value?: string | null) => {
571
574
  if (!value) return;
572
575
  const normalized = normalizeUrl(value);
573
- if (normalized) visitCounts.set(normalized, (visitCounts.get(normalized) || 0) + 1);
576
+ visitCounts.set(normalized, (visitCounts.get(normalized) || 0) + 1);
574
577
  };
575
578
 
576
579
  for (const transition of history) {
@@ -581,7 +584,7 @@ class Navigator implements Agent {
581
584
  if (opts?.visitedUrls) {
582
585
  for (const url of opts.visitedUrls) {
583
586
  const normalized = normalizeUrl(url);
584
- if (normalized && !visitCounts.has(normalized)) {
587
+ if (!visitCounts.has(normalized)) {
585
588
  visitCounts.set(normalized, 1);
586
589
  }
587
590
  }
@@ -682,7 +685,7 @@ class Navigator implements Agent {
682
685
  return suggestion;
683
686
  }
684
687
 
685
- async verifyState(message: string, actionResult: ActionResult): Promise<{ verified: boolean; inexpressible: boolean; results: AssertionResult[]; successfulCodes: string[]; assertionSteps: Array<{ name: string; args: any[] }>; totalAttempted: number }> {
688
+ async verifyState(message: string, actionResult: ActionResult): Promise<{ verified: boolean; inexpressible: boolean; results: AssertionResult[]; successfulCodes: string[]; assertionSteps: Array<{ name: string; args: any[] }>; totalAttempted: number; judged?: Decision }> {
686
689
  tag('info').log('AI Navigator verifying state at', actionResult.url);
687
690
  debugLog('Verification message:', message);
688
691
 
@@ -692,6 +695,13 @@ class Navigator implements Agent {
692
695
  return { verified: cachedVerification, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
693
696
  }
694
697
 
698
+ const verifiedClaims = Object.keys(actionResult.verifications ?? {}).filter((claim) => actionResult.getVerification(claim) === true);
699
+ const same = await this.judge?.decide('Which already verified claim means the same as the claim under consideration?', [...verifiedClaims, UNDECIDED], { claim: message });
700
+ if (same?.approved) {
701
+ tag('operation').log(`Judge matched claim to an already verified one: "${same.value}"`);
702
+ return { verified: true, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
703
+ }
704
+
695
705
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
696
706
  let experience = '';
697
707
 
@@ -832,7 +842,8 @@ class Navigator implements Agent {
832
842
  const inexpressible = !alreadyVerified && totalAttempted === 0;
833
843
  if (inexpressible) {
834
844
  tag('warning').log('No assertion could express this claim');
835
- return { verified: false, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
845
+ const judged = await this.judge?.decide('The page shows that this claim is true.', null, { claim: message, page: actionResult.getCompactARIA().slice(0, JUDGE_PAGE_CAP) });
846
+ return { verified: false, inexpressible, results, successfulCodes, assertionSteps, totalAttempted, judged };
836
847
  }
837
848
 
838
849
  actionResult.addVerification(message, verified);
package/src/ai/pilot.ts CHANGED
@@ -8,7 +8,7 @@ import type Explorer from '../explorer.ts';
8
8
  import type { PlaywrightRecorder } from '../playwright-recorder.ts';
9
9
  import type { StateManager } from '../state-manager.ts';
10
10
  import { Stats } from '../stats.ts';
11
- import { type Test, TestResult } from '../test-plan.ts';
11
+ import { type Test, TestResult, TestStatus } from '../test-plan.ts';
12
12
  import { collectInteractiveNodes } from '../utils/aria.ts';
13
13
  import { ErrorPageError } from '../utils/error-page.ts';
14
14
  import { createDebug, tag } from '../utils/logger.ts';
@@ -19,6 +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
23
  import type { Navigator } from './navigator.ts';
23
24
  import type { Provider } from './provider.ts';
24
25
  import type { Researcher } from './researcher.ts';
@@ -33,6 +34,10 @@ const PILOT_REASONING_LIMIT = 500;
33
34
  const PILOT_MESSAGE_LIMIT = 2;
34
35
  const PILOT_MESSAGE_MAX_LENGTH = 160;
35
36
  const PILOT_REQUEST_LIMIT = 5;
37
+ const OUTCOME_STATUS: Record<string, SettledStatus> = {
38
+ 'The run shows this outcome happened.': 'passed',
39
+ 'The run shows this outcome did not happen.': 'failed',
40
+ };
36
41
 
37
42
  export class Pilot implements Agent {
38
43
  emoji = '🧭';
@@ -45,6 +50,7 @@ export class Pilot implements Agent {
45
50
  private requestStore: RequestStore;
46
51
  private playwrightRecorder: PlaywrightRecorder;
47
52
  private fisherman: Fisherman | null = null;
53
+ private judge?: Judge;
48
54
 
49
55
  constructor(deps: AgentDeps, agentTools: any, researcher: Researcher) {
50
56
  this.provider = deps.ai;
@@ -54,6 +60,7 @@ export class Pilot implements Agent {
54
60
  this.stateManager = deps.stateManager;
55
61
  this.requestStore = deps.requestStore;
56
62
  this.playwrightRecorder = deps.playwrightRecorder;
63
+ this.judge = deps.judge;
57
64
  }
58
65
 
59
66
  setFisherman(fisherman: Fisherman): void {
@@ -548,6 +555,9 @@ export class Pilot implements Agent {
548
555
  const actionsContext = this.formatActions(toolCalls);
549
556
  const stateContext = this.buildStateContext(currentState);
550
557
 
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
+
551
561
  const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
552
562
 
553
563
  const text = await this.sendToPilot(
@@ -592,7 +602,11 @@ export class Pilot implements Agent {
592
602
 
593
603
  let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
594
604
  if (image) undecided = task.expected;
595
- if (!undecided.length) return task.expected.map((text) => ({ text, status: decided(text) }));
605
+
606
+ let settledByJudge = new Map<string, SettledStatus>();
607
+ if (!image) settledByJudge = await this.settleByJudge(task, undecided);
608
+ undecided = undecided.filter((text) => !settledByJudge.has(text));
609
+ if (!undecided.length) return task.expected.map((text) => ({ text, status: settledByJudge.get(text) || decided(text) }));
596
610
 
597
611
  const schema = z.object({
598
612
  outcomes: z.array(
@@ -665,6 +679,8 @@ export class Pilot implements Agent {
665
679
 
666
680
  const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome]));
667
681
  return task.expected.map((text) => {
682
+ const byJudge = settledByJudge.get(text);
683
+ if (byJudge) return { text, status: byJudge };
668
684
  if (!undecided.includes(text)) return { text, status: decided(text) };
669
685
  const outcome = judged.get(text) as { status: SettledStatus; evidence?: string } | undefined;
670
686
  if (!outcome) return { text, status: 'unverified' as SettledStatus };
@@ -672,6 +688,22 @@ export class Pilot implements Agent {
672
688
  });
673
689
  }
674
690
 
691
+ private async settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>> {
692
+ const settled = new Map<string, SettledStatus>();
693
+ const judge = this.judge;
694
+ if (!judge) return settled;
695
+
696
+ const state = { scenario: task.scenario, runLog: task.notesToString() || 'No steps recorded.' };
697
+ await Promise.all(
698
+ expectations.map(async (text) => {
699
+ const decision = await judge.decide(`What did this run establish about the expected outcome: ${text}`, [...Object.keys(OUTCOME_STATUS), UNDECIDED], state);
700
+ const status = OUTCOME_STATUS[decision.value ?? ''];
701
+ if (status) settled.set(text, status);
702
+ })
703
+ );
704
+ return settled;
705
+ }
706
+
675
707
  private formatExpectations(task: Test): string {
676
708
  const checked = task.getCheckedExpectations();
677
709
  const remaining = task.getRemainingExpectations();
@@ -690,6 +722,7 @@ export class Pilot implements Agent {
690
722
  this.conversation!.addUserText(finalUserText);
691
723
 
692
724
  const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
725
+ const preparedCount = opts.task.preparedData.length;
693
726
 
694
727
  const result = await this.provider.invokeConversation(this.conversation!, tools, {
695
728
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
@@ -698,7 +731,7 @@ export class Pilot implements Agent {
698
731
  stopWhen: () => opts.task.hasFinished,
699
732
  telemetry: { functionId },
700
733
  });
701
- const text = result?.response?.text || '';
734
+ const text = this.announcePreparedData(result?.response?.text || '', opts.task, preparedCount);
702
735
  const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => ({ url: e.output.url, content: e.output.content }));
703
736
  if (learned.length === 0) return text;
704
737
  opts.task.applyExperience(learned);
@@ -714,6 +747,24 @@ export class Pilot implements Agent {
714
747
  `;
715
748
  }
716
749
 
750
+ private announcePreparedData(text: string, task: Test, preparedCount: number): string {
751
+ const prepared = task.preparedData.slice(preparedCount);
752
+ if (prepared.length === 0) return text;
753
+
754
+ let refresh = '';
755
+ if (task.status === TestStatus.IN_PROGRESS) refresh = 'It was created after the page loaded, so the page does not show it yet. Run I.refreshPage() through form() before looking for it.';
756
+
757
+ return dedent`
758
+ ${text}
759
+
760
+ <prepared_data>
761
+ Pilot created this data through the API for this test. Use it instead of creating the same data through the UI:
762
+ ${prepared.map((item) => `- ${item}`).join('\n')}
763
+ ${refresh}
764
+ </prepared_data>
765
+ `;
766
+ }
767
+
717
768
  private getExperienceToc(): string {
718
769
  const state = this.stateManager.getCurrentState();
719
770
  if (!state) return '';
@@ -721,7 +772,7 @@ export class Pilot implements Agent {
721
772
  }
722
773
 
723
774
  private pickPlanningTools() {
724
- const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser } = this.agentTools ?? {};
775
+ const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser, judge } = this.agentTools ?? {};
725
776
  const planning: Record<string, unknown> = {};
726
777
  if (see) planning.see = see;
727
778
  if (context) planning.context = context;
@@ -731,6 +782,7 @@ export class Pilot implements Agent {
731
782
  if (xpathCheck) planning.xpathCheck = xpathCheck;
732
783
  if (learnExperience) planning.learnExperience = learnExperience;
733
784
  if (askUser) planning.askUser = askUser;
785
+ if (judge) planning.judge = judge;
734
786
  withdrawVisionTools(planning);
735
787
  return planning;
736
788
  }
@@ -777,6 +829,7 @@ export class Pilot implements Agent {
777
829
  });
778
830
  const stepText = `Precondition: created ${items.join(', ')}`;
779
831
  task.addStep(stepText);
832
+ task.preparedData.push(...items);
780
833
  tag('success').log(stepText);
781
834
 
782
835
  return { noted: true, prepared: true, created: result.created };
@@ -1157,7 +1210,7 @@ export class Pilot implements Agent {
1157
1210
  Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1158
1211
  visualClick, back, getVisitedStates, reset, stop, finish, record.
1159
1212
  Use tool names exactly as listed. Do not invent combined names or aliases.
1160
- Reloading is not a tool: to re-read a page from the server, instruct Tester to run I.reloadPage() through form.
1213
+ Reloading is not a tool: to re-read a page from the server, instruct Tester to run I.refreshPage() through form.
1161
1214
 
1162
1215
  ${capabilityGroundingRule}
1163
1216
 
package/src/ai/planner.ts CHANGED
@@ -301,13 +301,14 @@ export class Planner extends PlannerBase implements Agent {
301
301
  .replaceEach((section) => {
302
302
  const heading = section.query('heading').text().trim();
303
303
  const withoutHeadings = mdq(section.text()).query('heading').replace('');
304
- const body = mdq(withoutHeadings).query('hr').replace('').trim();
304
+ const body = mdq(withoutHeadings).query('hr').replace('').toString().trim();
305
305
  if (body && !seenTitles.has(heading)) {
306
306
  seenTitles.add(heading);
307
307
  return section.text();
308
308
  }
309
309
  return '';
310
- });
310
+ })
311
+ .toString();
311
312
  }
312
313
 
313
314
  const trimmedTitles = new Set<string>();
@@ -319,10 +320,11 @@ export class Planner extends PlannerBase implements Agent {
319
320
  if (trimmedTitles.has(heading)) return section.text();
320
321
  const count = section.query('blockquote').count();
321
322
  if (count <= 10) return section.text();
322
- const kept = mdq(section.text()).query('blockquote[10:]').replace('');
323
+ const kept = mdq(section.text()).query('blockquote[10:]').replace('').toString();
323
324
  trimmedTitles.add(heading);
324
325
  return `${kept.trimEnd()}\n> ... and ${count - 10} more discoveries\n`;
325
- });
326
+ })
327
+ .toString();
326
328
  }
327
329
 
328
330
  return result.trim() || null;
@@ -404,7 +406,7 @@ export class Planner extends PlannerBase implements Agent {
404
406
  if (this.scout && this.docsWeight > 0) {
405
407
  docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
406
408
  }
407
- let plannerResearch = mdq(research).query('code').replace('');
409
+ let plannerResearch = mdq(research).query('code').replace('').toString();
408
410
  plannerResearch = mdq(plannerResearch)
409
411
  .query('table')
410
412
  .replaceEach((table) => {
@@ -415,7 +417,8 @@ export class Planner extends PlannerBase implements Agent {
415
417
  Type: r.Type || '',
416
418
  }));
417
419
  return jsonToTable(elementWithType, ['Element', 'Type']);
418
- });
420
+ })
421
+ .toString();
419
422
 
420
423
  const hasFocusedOverlay = hasFocusedSection(plannerResearch);
421
424
  const focusNote = hasFocusedOverlay ? "IMPORTANT: One section is marked as **Focused** — this is the user's current focus area. Concentrate testing on the Focused section FIRST — test all interactions inside it before planning tests for the rest of the page." : '';