explorbot 0.4.9 → 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 (144) 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/action.js +4 -2
  12. package/dist/src/ai/agent.d.ts +3 -1
  13. package/dist/src/ai/judge-provider.d.ts +17 -0
  14. package/dist/src/ai/judge-provider.js +56 -0
  15. package/dist/src/ai/judge-tool.d.ts +2 -0
  16. package/dist/src/ai/judge-tool.js +33 -0
  17. package/dist/src/ai/judge.d.ts +28 -0
  18. package/dist/src/ai/judge.js +71 -0
  19. package/dist/src/ai/navigator.d.ts +3 -1
  20. package/dist/src/ai/navigator.js +24 -28
  21. package/dist/src/ai/pilot.d.ts +4 -0
  22. package/dist/src/ai/pilot.js +67 -9
  23. package/dist/src/ai/planner.js +9 -6
  24. package/dist/src/ai/provider.d.ts +4 -1
  25. package/dist/src/ai/provider.js +52 -7
  26. package/dist/src/ai/rerunner.js +7 -0
  27. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  28. package/dist/src/ai/researcher/locators.js +2 -2
  29. package/dist/src/ai/researcher/pagination.js +1 -1
  30. package/dist/src/ai/researcher/research-result.js +2 -2
  31. package/dist/src/ai/researcher.js +1 -1
  32. package/dist/src/ai/task-agent.d.ts +2 -0
  33. package/dist/src/ai/task-agent.js +3 -1
  34. package/dist/src/ai/tester.js +19 -15
  35. package/dist/src/ai/tools.d.ts +4 -3
  36. package/dist/src/ai/tools.js +35 -7
  37. package/dist/src/api/request-result.js +2 -1
  38. package/dist/src/api/xhr-capture.js +2 -1
  39. package/dist/src/command-handler.d.ts +1 -0
  40. package/dist/src/command-handler.js +24 -3
  41. package/dist/src/commands/base-command.d.ts +5 -0
  42. package/dist/src/commands/base-command.js +3 -0
  43. package/dist/src/commands/explore-command.d.ts +2 -1
  44. package/dist/src/commands/explore-command.js +12 -1
  45. package/dist/src/commands/freesail-command.js +8 -2
  46. package/dist/src/commands/init-command.js +1 -1
  47. package/dist/src/commands/navigate-command.d.ts +2 -1
  48. package/dist/src/commands/navigate-command.js +6 -0
  49. package/dist/src/commands/plan-load-command.d.ts +2 -1
  50. package/dist/src/commands/plan-load-command.js +4 -0
  51. package/dist/src/commands/plans-command.d.ts +3 -9
  52. package/dist/src/commands/plans-command.js +11 -21
  53. package/dist/src/commands/rerun-command.d.ts +2 -1
  54. package/dist/src/commands/rerun-command.js +5 -1
  55. package/dist/src/commands/research-command.d.ts +2 -1
  56. package/dist/src/commands/research-command.js +6 -0
  57. package/dist/src/commands/test-command.d.ts +2 -1
  58. package/dist/src/commands/test-command.js +4 -1
  59. package/dist/src/components/Autocomplete.js +26 -12
  60. package/dist/src/components/InputReadline.js +10 -1
  61. package/dist/src/config.d.ts +6 -0
  62. package/dist/src/experience-tracker.js +4 -3
  63. package/dist/src/explorbot.d.ts +3 -0
  64. package/dist/src/explorbot.js +8 -0
  65. package/dist/src/explorer.js +2 -3
  66. package/dist/src/knowledge-tracker.js +1 -1
  67. package/dist/src/reporter.js +8 -4
  68. package/dist/src/state-manager.d.ts +2 -0
  69. package/dist/src/state-manager.js +16 -0
  70. package/dist/src/test-plan.d.ts +11 -0
  71. package/dist/src/test-plan.js +54 -2
  72. package/dist/src/utils/aria-ref.js +1 -1
  73. package/dist/src/utils/logger.js +9 -3
  74. package/dist/src/utils/markdown-query.d.ts +1 -48
  75. package/dist/src/utils/markdown-query.js +1 -444
  76. package/dist/src/utils/mdq/cli.d.ts +6 -0
  77. package/dist/src/utils/mdq/cli.js +122 -0
  78. package/dist/src/utils/mdq/edit.d.ts +24 -0
  79. package/dist/src/utils/mdq/edit.js +147 -0
  80. package/dist/src/utils/mdq/query.d.ts +118 -0
  81. package/dist/src/utils/mdq/query.js +451 -0
  82. package/dist/src/utils/step-analyzer.d.ts +3 -0
  83. package/dist/src/utils/step-analyzer.js +7 -0
  84. package/dist/src/utils/strings.d.ts +1 -0
  85. package/dist/src/utils/strings.js +7 -0
  86. package/dist/src/utils/test-files.d.ts +1 -0
  87. package/dist/src/utils/test-files.js +5 -2
  88. package/dist/src/utils/url-matcher.d.ts +1 -0
  89. package/dist/src/utils/url-matcher.js +7 -0
  90. package/docs/api-testing/planning.md +1 -1
  91. package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
  92. package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
  93. package/package.json +3 -2
  94. package/src/action.ts +4 -2
  95. package/src/ai/agent.ts +3 -1
  96. package/src/ai/judge-provider.ts +62 -0
  97. package/src/ai/judge-tool.ts +35 -0
  98. package/src/ai/judge.ts +75 -0
  99. package/src/ai/navigator.ts +24 -27
  100. package/src/ai/pilot.ts +68 -9
  101. package/src/ai/planner.ts +9 -6
  102. package/src/ai/provider.ts +51 -7
  103. package/src/ai/rerunner.ts +4 -0
  104. package/src/ai/researcher/deep-analysis.ts +2 -2
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/pagination.ts +1 -1
  107. package/src/ai/researcher/research-result.ts +2 -2
  108. package/src/ai/researcher.ts +1 -1
  109. package/src/ai/task-agent.ts +4 -1
  110. package/src/ai/tester.ts +19 -16
  111. package/src/ai/tools.ts +42 -7
  112. package/src/api/request-result.ts +2 -1
  113. package/src/api/xhr-capture.ts +2 -1
  114. package/src/command-handler.ts +28 -3
  115. package/src/commands/base-command.ts +9 -0
  116. package/src/commands/explore-command.ts +15 -2
  117. package/src/commands/freesail-command.ts +9 -2
  118. package/src/commands/init-command.ts +1 -1
  119. package/src/commands/navigate-command.ts +8 -1
  120. package/src/commands/plan-load-command.ts +6 -1
  121. package/src/commands/plans-command.ts +13 -29
  122. package/src/commands/rerun-command.ts +7 -2
  123. package/src/commands/research-command.ts +8 -1
  124. package/src/commands/test-command.ts +6 -2
  125. package/src/components/Autocomplete.tsx +39 -10
  126. package/src/components/InputReadline.tsx +10 -1
  127. package/src/config.ts +1 -0
  128. package/src/experience-tracker.ts +4 -3
  129. package/src/explorbot.ts +8 -0
  130. package/src/explorer.ts +2 -2
  131. package/src/knowledge-tracker.ts +1 -1
  132. package/src/reporter.ts +8 -4
  133. package/src/state-manager.ts +16 -0
  134. package/src/test-plan.ts +67 -2
  135. package/src/utils/aria-ref.ts +1 -1
  136. package/src/utils/logger.ts +7 -2
  137. package/src/utils/markdown-query.ts +1 -519
  138. package/src/utils/mdq/cli.ts +118 -0
  139. package/src/utils/mdq/edit.ts +158 -0
  140. package/src/utils/mdq/query.ts +556 -0
  141. package/src/utils/step-analyzer.ts +8 -0
  142. package/src/utils/strings.ts +7 -0
  143. package/src/utils/test-files.ts +5 -2
  144. package/src/utils/url-matcher.ts +7 -0
@@ -0,0 +1,33 @@
1
+ import { tool } from 'ai';
2
+ import dedent from 'dedent';
3
+ import { z } from 'zod';
4
+ import { failedToolResult, successToolResult } from "./tools.js";
5
+ export function createJudgeTool(deps, buildState) {
6
+ const judge = deps.judge;
7
+ if (!judge?.toolEnabled)
8
+ return {};
9
+ return {
10
+ judge: tool({
11
+ description: dedent `
12
+ Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
13
+ `,
14
+ inputSchema: z.object({
15
+ question: z.string().describe('The statement to confirm, or the question the options answer'),
16
+ options: z.array(z.string()).optional().describe('Possible answers. Omit to confirm a statement'),
17
+ context: z.string().optional().describe('Anything the page observation does not already carry'),
18
+ }),
19
+ execute: async ({ question, options, context }) => {
20
+ const state = await buildState();
21
+ if (context)
22
+ state.context = context;
23
+ const decision = await judge.consult(question, options ?? null, state);
24
+ if (decision.rejected) {
25
+ return failedToolResult('judge', `Not confirmed: ${question}`, {
26
+ suggestion: 'The page does not settle this. Gather more context or take another route; do not assume either answer.',
27
+ });
28
+ }
29
+ return successToolResult('judge', { question, answer: decision.value, confidence: decision.confidence });
30
+ },
31
+ }),
32
+ };
33
+ }
@@ -0,0 +1,28 @@
1
+ import type { AIConfig } from '../config.js';
2
+ import { JudgeProvider } from './judge-provider.js';
3
+ export declare const UNDECIDED = "undecided";
4
+ export declare const JUDGE_PAGE_CAP = 12000;
5
+ export declare class Decision {
6
+ readonly value: string | null;
7
+ readonly confidence: number;
8
+ constructor(value: string | null, confidence: number);
9
+ get approved(): boolean;
10
+ get rejected(): boolean;
11
+ }
12
+ export declare class Judge {
13
+ provider: JudgeProvider;
14
+ enabled: {
15
+ tool: boolean;
16
+ direct: boolean;
17
+ };
18
+ constructor(provider: JudgeProvider, enabled: {
19
+ tool: boolean;
20
+ direct: boolean;
21
+ });
22
+ static fromConfig(config: AIConfig['decisionModel']): Judge | null;
23
+ get toolEnabled(): boolean;
24
+ decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
25
+ consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
26
+ request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
27
+ recordFailure(error: unknown): null;
28
+ }
@@ -0,0 +1,71 @@
1
+ import { clearActivity, setActivity } from "../activity.js";
2
+ import { Observability } from "../observability.js";
3
+ import { createDebug } from "../utils/logger.js";
4
+ import { JudgeProvider } from "./judge-provider.js";
5
+ const debugLog = createDebug('explorbot:judge');
6
+ const APPROVAL_THRESHOLD = 0.7;
7
+ export const UNDECIDED = 'undecided';
8
+ export const JUDGE_PAGE_CAP = 12000;
9
+ export class Decision {
10
+ value;
11
+ confidence;
12
+ constructor(value, confidence) {
13
+ this.value = value;
14
+ this.confidence = confidence;
15
+ }
16
+ get approved() {
17
+ return this.value !== null;
18
+ }
19
+ get rejected() {
20
+ return this.value === null;
21
+ }
22
+ }
23
+ export class Judge {
24
+ provider;
25
+ enabled;
26
+ constructor(provider, enabled) {
27
+ this.provider = provider;
28
+ this.enabled = enabled;
29
+ }
30
+ static fromConfig(config) {
31
+ if (!config)
32
+ return null;
33
+ return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false });
34
+ }
35
+ get toolEnabled() {
36
+ return this.enabled.tool;
37
+ }
38
+ async decide(question, options, state) {
39
+ if (!this.enabled.direct)
40
+ return new Decision(null, 0);
41
+ return this.consult(question, options, state);
42
+ }
43
+ async consult(question, options, state) {
44
+ if (Array.isArray(options) && options.length < 2)
45
+ return new Decision(null, 0);
46
+ return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
47
+ setActivity('⚖️ Asking judge...', 'ai');
48
+ const decision = await this.request(question, options, state).finally(() => clearActivity());
49
+ Observability.getSpan()?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence }));
50
+ return decision;
51
+ });
52
+ }
53
+ async request(question, options, state) {
54
+ let list;
55
+ if (Array.isArray(options))
56
+ list = options;
57
+ const answer = await this.provider.decide(state, question, list).catch((error) => this.recordFailure(error));
58
+ if (!answer)
59
+ return new Decision(null, 0);
60
+ if (answer.probability <= APPROVAL_THRESHOLD)
61
+ return new Decision(null, answer.probability);
62
+ if (answer.value === UNDECIDED)
63
+ return new Decision(null, answer.probability);
64
+ return new Decision(answer.value, answer.probability);
65
+ }
66
+ recordFailure(error) {
67
+ debugLog('judge declined: %s', error);
68
+ Observability.getSpan()?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
69
+ return null;
70
+ }
71
+ }
@@ -8,6 +8,7 @@ import type { KnowledgeTracker } from '../knowledge-tracker.js';
8
8
  import { type StateManager } from '../state-manager.js';
9
9
  import { HooksRunner } from '../utils/hooks-runner.js';
10
10
  import type { Agent, AgentDeps } from './agent.js';
11
+ import { type Decision, type Judge } from './judge.js';
11
12
  import type { Provider } from './provider.js';
12
13
  declare class Navigator implements Agent {
13
14
  emoji: string;
@@ -23,10 +24,10 @@ declare class Navigator implements Agent {
23
24
  explorer: Explorer;
24
25
  config: ExplorbotConfig;
25
26
  stateManager: StateManager;
27
+ judge?: Judge;
26
28
  constructor(deps: AgentDeps);
27
29
  get verifyAttempts(): number;
28
30
  get verifyTimeout(): number;
29
- getBaseOrigin(): string | null;
30
31
  getComparableCurrentUrl(stateManager: any, expectedUrl: string): string;
31
32
  comparableUrl(state: {
32
33
  url?: string;
@@ -83,6 +84,7 @@ declare class Navigator implements Agent {
83
84
  args: any[];
84
85
  }>;
85
86
  totalAttempted: number;
87
+ judged?: Decision;
86
88
  }>;
87
89
  checkAlreadyVerified(aiResponse: string, actionResult: ActionResult): boolean;
88
90
  }
@@ -12,7 +12,8 @@ import { createDebug, pluralize, tag } from '../utils/logger.js';
12
12
  import { loop, pause } from '../utils/loop.js';
13
13
  import { RulesLoader } from "../utils/rules-loader.js";
14
14
  import { normalizeInlineText } from "../utils/strings.js";
15
- import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
15
+ import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
16
+ import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
16
17
  import { Researcher } from "./researcher.js";
17
18
  import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
18
19
  import { isInteractive } from './task-agent.js';
@@ -67,6 +68,7 @@ class Navigator {
67
68
  explorer;
68
69
  config;
69
70
  stateManager;
71
+ judge;
70
72
  constructor(deps) {
71
73
  this.provider = deps.ai;
72
74
  this.explorer = deps.explorer;
@@ -75,6 +77,7 @@ class Navigator {
75
77
  this.knowledgeTracker = deps.knowledgeTracker;
76
78
  this.experienceTracker = deps.stateManager.getExperienceTracker();
77
79
  this.hooksRunner = new HooksRunner(deps.explorer, deps.config);
80
+ this.judge = deps.judge;
78
81
  }
79
82
  get verifyAttempts() {
80
83
  return this.config.ai?.agents?.navigator?.verifyAttempts ?? 3;
@@ -82,15 +85,6 @@ class Navigator {
82
85
  get verifyTimeout() {
83
86
  return this.config.ai?.agents?.navigator?.verifyTimeout ?? 1500;
84
87
  }
85
- getBaseOrigin() {
86
- const baseUrl = this.config.playwright.url;
87
- try {
88
- return new URL(baseUrl).origin;
89
- }
90
- catch {
91
- return null;
92
- }
93
- }
94
88
  getComparableCurrentUrl(stateManager, expectedUrl) {
95
89
  const currentState = stateManager.getCurrentState();
96
90
  if (!currentState)
@@ -109,19 +103,14 @@ class Navigator {
109
103
  const currentFullUrl = currentState.fullUrl || currentState.url || '';
110
104
  if (!currentFullUrl)
111
105
  return false;
112
- try {
113
- const currentOrigin = new URL(currentFullUrl).origin;
114
- if (/^https?:\/\//i.test(expectedUrl)) {
115
- return currentOrigin === new URL(expectedUrl).origin;
116
- }
117
- const baseOrigin = this.getBaseOrigin();
118
- if (!baseOrigin)
119
- return true;
120
- return currentOrigin === baseOrigin;
121
- }
122
- catch {
106
+ if (!/^https?:\/\//i.test(currentFullUrl))
123
107
  return !/^https?:\/\//i.test(expectedUrl);
124
- }
108
+ if (/^https?:\/\//i.test(expectedUrl))
109
+ return isSameHostFamily(currentFullUrl, expectedUrl);
110
+ const baseUrl = this.config.playwright.url;
111
+ if (!baseUrl)
112
+ return true;
113
+ return isSameHostFamily(currentFullUrl, baseUrl);
125
114
  }
126
115
  isOnExpectedPage(expectedUrl, stateManager) {
127
116
  if (!this.isSameExpectedOrigin(expectedUrl, stateManager)) {
@@ -289,8 +278,9 @@ class Navigator {
289
278
  tag('warning').log(`Page state did not change at ${check.freshState.url}`);
290
279
  }
291
280
  else {
292
- lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
293
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
281
+ const reachedUrl = check.freshState.fullUrl || check.freshState.url;
282
+ lastFailure = `Reached ${reachedUrl}, expected ${expectedUrl}`;
283
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${reachedUrl}`);
294
284
  }
295
285
  batchFailures.push({
296
286
  code: codeBlock,
@@ -530,8 +520,7 @@ class Navigator {
530
520
  if (!value)
531
521
  return;
532
522
  const normalized = normalizeUrl(value);
533
- if (normalized)
534
- visitCounts.set(normalized, (visitCounts.get(normalized) || 0) + 1);
523
+ visitCounts.set(normalized, (visitCounts.get(normalized) || 0) + 1);
535
524
  };
536
525
  for (const transition of history) {
537
526
  countVisit(transition.toState.url);
@@ -540,7 +529,7 @@ class Navigator {
540
529
  if (opts?.visitedUrls) {
541
530
  for (const url of opts.visitedUrls) {
542
531
  const normalized = normalizeUrl(url);
543
- if (normalized && !visitCounts.has(normalized)) {
532
+ if (!visitCounts.has(normalized)) {
544
533
  visitCounts.set(normalized, 1);
545
534
  }
546
535
  }
@@ -632,6 +621,12 @@ class Navigator {
632
621
  tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
633
622
  return { verified: cachedVerification, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
634
623
  }
624
+ const verifiedClaims = Object.keys(actionResult.verifications ?? {}).filter((claim) => actionResult.getVerification(claim) === true);
625
+ const same = await this.judge?.decide('Which already verified claim means the same as the claim under consideration?', [...verifiedClaims, UNDECIDED], { claim: message });
626
+ if (same?.approved) {
627
+ tag('operation').log(`Judge matched claim to an already verified one: "${same.value}"`);
628
+ return { verified: true, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
629
+ }
635
630
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
636
631
  let experience = '';
637
632
  if (!actionResult.isInsideIframe) {
@@ -754,7 +749,8 @@ class Navigator {
754
749
  const inexpressible = !alreadyVerified && totalAttempted === 0;
755
750
  if (inexpressible) {
756
751
  tag('warning').log('No assertion could express this claim');
757
- return { verified: false, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
752
+ 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) });
753
+ return { verified: false, inexpressible, results, successfulCodes, assertionSteps, totalAttempted, judged };
758
754
  }
759
755
  actionResult.addVerification(message, verified);
760
756
  this.stateManager.updateState(actionResult);
@@ -7,6 +7,7 @@ import { type Test } from '../test-plan.js';
7
7
  import type { Agent, AgentDeps } from './agent.js';
8
8
  import type { Conversation } from './conversation.js';
9
9
  import type { Fisherman } from './fisherman.js';
10
+ import { type Judge } from './judge.js';
10
11
  import type { Navigator } from './navigator.js';
11
12
  import type { Provider } from './provider.js';
12
13
  import type { Researcher } from './researcher.js';
@@ -21,6 +22,7 @@ export declare class Pilot implements Agent {
21
22
  requestStore: RequestStore;
22
23
  playwrightRecorder: PlaywrightRecorder;
23
24
  fisherman: Fisherman | null;
25
+ judge?: Judge;
24
26
  constructor(deps: AgentDeps, agentTools: any, researcher: Researcher);
25
27
  setFisherman(fisherman: Fisherman): void;
26
28
  get stepsToReview(): number;
@@ -41,12 +43,14 @@ export declare class Pilot implements Agent {
41
43
  reviewNewPage(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
42
44
  analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
43
45
  settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
46
+ settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>>;
44
47
  formatExpectations(task: Test): string;
45
48
  sendToPilot(userText: string, functionId: string, opts: {
46
49
  tools?: boolean;
47
50
  maxToolRoundtrips?: number;
48
51
  task: Test;
49
52
  }): Promise<string>;
53
+ announcePreparedData(text: string, task: Test, preparedCount: number): string;
50
54
  getExperienceToc(): string;
51
55
  pickPlanningTools(): Record<string, unknown>;
52
56
  fishermanStatus(): string;
@@ -4,13 +4,14 @@ import { z } from 'zod';
4
4
  import { ActionResult } from "../action-result.js";
5
5
  import { ConfigParser } from "../config.js";
6
6
  import { Stats } from "../stats.js";
7
- import { TestResult } from "../test-plan.js";
7
+ import { TestResult, TestStatus } from "../test-plan.js";
8
8
  import { collectInteractiveNodes } from "../utils/aria.js";
9
9
  import { ErrorPageError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  const debugLog = createDebug('explorbot:pilot');
12
12
  import { truncateJson } from "../utils/strings.js";
13
13
  import { createAskApiTool } from "./fisherman/tools.js";
14
+ import { UNDECIDED } from "./judge.js";
14
15
  import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
15
16
  import { isInteractive } from "./task-agent.js";
16
17
  import { withdrawVisionTools } from "./tools.js";
@@ -21,6 +22,10 @@ const PILOT_REASONING_LIMIT = 500;
21
22
  const PILOT_MESSAGE_LIMIT = 2;
22
23
  const PILOT_MESSAGE_MAX_LENGTH = 160;
23
24
  const PILOT_REQUEST_LIMIT = 5;
25
+ const OUTCOME_STATUS = {
26
+ 'The run shows this outcome happened.': 'passed',
27
+ 'The run shows this outcome did not happen.': 'failed',
28
+ };
24
29
  export class Pilot {
25
30
  emoji = '🧭';
26
31
  provider;
@@ -32,6 +37,7 @@ export class Pilot {
32
37
  requestStore;
33
38
  playwrightRecorder;
34
39
  fisherman = null;
40
+ judge;
35
41
  constructor(deps, agentTools, researcher) {
36
42
  this.provider = deps.ai;
37
43
  this.agentTools = agentTools;
@@ -40,6 +46,7 @@ export class Pilot {
40
46
  this.stateManager = deps.stateManager;
41
47
  this.requestStore = deps.requestStore;
42
48
  this.playwrightRecorder = deps.playwrightRecorder;
49
+ this.judge = deps.judge;
43
50
  }
44
51
  setFisherman(fisherman) {
45
52
  this.fisherman = fisherman;
@@ -95,7 +102,9 @@ export class Pilot {
95
102
  }
96
103
  }
97
104
  const schema = z.object({
98
- decision: z.enum(['pass', 'fail', 'continue', 'skipped']).describe('pass = test succeeded, fail = test failed, continue = tester should keep going, skipped = scenario is irrelevant OR systematic execution failures prevented testing'),
105
+ decision: z
106
+ .enum(['pass', 'fail', 'continue', 'skipped'])
107
+ .describe('pass = scenario goal accomplished, fail = the app misbehaved, continue = tester should keep going, skipped = the scenario cannot be judged against this app (its premise does not hold, it is irrelevant, or systematic execution failures prevented testing)'),
99
108
  reason: z.string().describe('Concise user-facing reason, maximum 1 short sentence and 120 characters. Do NOT repeat the decision status; explain only the evidence. For continue: explain why rejected and suggest alternatives.'),
100
109
  guidance: z.string().nullable().describe('Required for "continue": specific actionable instruction for the tester — what exactly to verify, retry differently, or complete next. Be concrete.'),
101
110
  requestVerification: z
@@ -358,9 +367,13 @@ export class Pilot {
358
367
  DOM assertion can't be made.
359
368
  Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
360
369
  requested action, workflow, or entity detail goal.
361
- - "fail": goal not achieved and no further step toward it is available on the current page.
362
- - "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
363
- crashes) prevented testing. NOT for "test failed to interact"that's "fail" or "continue".
370
+ - "fail": the app misbehaved the scenario's action ran against the right target and the app
371
+ produced a wrong, broken, or missing outcome. Not reaching the goal is not by itself a fail.
372
+ - "skipped": the scenario cannot be judged against this app the page shows its premise does not
373
+ hold (the assumed constraint, field, or behaviour is designed differently), the target entity or
374
+ feature is not the one here, the scenario is irrelevant, OR systematic infrastructure failures
375
+ (LLM errors, crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or
376
+ "continue".
364
377
  - "continue": goal incomplete but the control for the NEXT step is present on the current page, or a
365
378
  concrete missing check would change your verdict. Guidance must name that step.
366
379
  If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
@@ -474,6 +487,9 @@ export class Pilot {
474
487
  const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
475
488
  const actionsContext = this.formatActions(toolCalls);
476
489
  const stateContext = this.buildStateContext(currentState);
490
+ const healthy = await this.judge?.decide('The run is moving toward the goal and can continue without a supervisor reviewing it now.', null, { scenario: task.scenario, state: stateContext, recentActions: actionsContext });
491
+ if (healthy?.approved)
492
+ return '';
477
493
  const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
478
494
  const text = await this.sendToPilot(dedent `
479
495
  START URL: ${task.startUrl}
@@ -510,8 +526,12 @@ export class Pilot {
510
526
  let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
511
527
  if (image)
512
528
  undecided = task.expected;
529
+ let settledByJudge = new Map();
530
+ if (!image)
531
+ settledByJudge = await this.settleByJudge(task, undecided);
532
+ undecided = undecided.filter((text) => !settledByJudge.has(text));
513
533
  if (!undecided.length)
514
- return task.expected.map((text) => ({ text, status: decided(text) }));
534
+ return task.expected.map((text) => ({ text, status: settledByJudge.get(text) || decided(text) }));
515
535
  const schema = z.object({
516
536
  outcomes: z.array(z.object({
517
537
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
@@ -575,6 +595,9 @@ export class Pilot {
575
595
  response = await settle(userContent, this.provider.getAgenticModel('pilot'));
576
596
  const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome]));
577
597
  return task.expected.map((text) => {
598
+ const byJudge = settledByJudge.get(text);
599
+ if (byJudge)
600
+ return { text, status: byJudge };
578
601
  if (!undecided.includes(text))
579
602
  return { text, status: decided(text) };
580
603
  const outcome = judged.get(text);
@@ -583,6 +606,20 @@ export class Pilot {
583
606
  return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
584
607
  });
585
608
  }
609
+ async settleByJudge(task, expectations) {
610
+ const settled = new Map();
611
+ const judge = this.judge;
612
+ if (!judge)
613
+ return settled;
614
+ const state = { scenario: task.scenario, runLog: task.notesToString() || 'No steps recorded.' };
615
+ await Promise.all(expectations.map(async (text) => {
616
+ const decision = await judge.decide(`What did this run establish about the expected outcome: ${text}`, [...Object.keys(OUTCOME_STATUS), UNDECIDED], state);
617
+ const status = OUTCOME_STATUS[decision.value ?? ''];
618
+ if (status)
619
+ settled.set(text, status);
620
+ }));
621
+ return settled;
622
+ }
586
623
  formatExpectations(task) {
587
624
  const checked = task.getCheckedExpectations();
588
625
  const remaining = task.getRemainingExpectations();
@@ -599,6 +636,7 @@ export class Pilot {
599
636
  }
600
637
  this.conversation.addUserText(finalUserText);
601
638
  const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
639
+ const preparedCount = opts.task.preparedData.length;
602
640
  const result = await this.provider.invokeConversation(this.conversation, tools, {
603
641
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
604
642
  toolChoice: opts.tools ? 'auto' : 'none',
@@ -606,7 +644,7 @@ export class Pilot {
606
644
  stopWhen: () => opts.task.hasFinished,
607
645
  telemetry: { functionId },
608
646
  });
609
- const text = result?.response?.text || '';
647
+ const text = this.announcePreparedData(result?.response?.text || '', opts.task, preparedCount);
610
648
  const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => ({ url: e.output.url, content: e.output.content }));
611
649
  if (learned.length === 0)
612
650
  return text;
@@ -622,6 +660,23 @@ export class Pilot {
622
660
  </applied_experience>
623
661
  `;
624
662
  }
663
+ announcePreparedData(text, task, preparedCount) {
664
+ const prepared = task.preparedData.slice(preparedCount);
665
+ if (prepared.length === 0)
666
+ return text;
667
+ let refresh = '';
668
+ if (task.status === TestStatus.IN_PROGRESS)
669
+ 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.';
670
+ return dedent `
671
+ ${text}
672
+
673
+ <prepared_data>
674
+ Pilot created this data through the API for this test. Use it instead of creating the same data through the UI:
675
+ ${prepared.map((item) => `- ${item}`).join('\n')}
676
+ ${refresh}
677
+ </prepared_data>
678
+ `;
679
+ }
625
680
  getExperienceToc() {
626
681
  const state = this.stateManager.getCurrentState();
627
682
  if (!state)
@@ -629,7 +684,7 @@ export class Pilot {
629
684
  return this.stateManager.getExperienceTracker().renderExperienceTocFor(ActionResult.fromState(state));
630
685
  }
631
686
  pickPlanningTools() {
632
- const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser } = this.agentTools ?? {};
687
+ const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser, judge } = this.agentTools ?? {};
633
688
  const planning = {};
634
689
  if (see)
635
690
  planning.see = see;
@@ -647,6 +702,8 @@ export class Pilot {
647
702
  planning.learnExperience = learnExperience;
648
703
  if (askUser)
649
704
  planning.askUser = askUser;
705
+ if (judge)
706
+ planning.judge = judge;
650
707
  withdrawVisionTools(planning);
651
708
  return planning;
652
709
  }
@@ -694,6 +751,7 @@ export class Pilot {
694
751
  });
695
752
  const stepText = `Precondition: created ${items.join(', ')}`;
696
753
  task.addStep(stepText);
754
+ task.preparedData.push(...items);
697
755
  tag('success').log(stepText);
698
756
  return { noted: true, prepared: true, created: result.created };
699
757
  },
@@ -1047,7 +1105,7 @@ export class Pilot {
1047
1105
  Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1048
1106
  visualClick, back, getVisitedStates, reset, stop, finish, record.
1049
1107
  Use tool names exactly as listed. Do not invent combined names or aliases.
1050
- Reloading is not a tool: to re-read a page from the server, instruct Tester to run I.reloadPage() through form.
1108
+ Reloading is not a tool: to re-read a page from the server, instruct Tester to run I.refreshPage() through form.
1051
1109
 
1052
1110
  ${capabilityGroundingRule}
1053
1111
 
@@ -263,13 +263,14 @@ export class Planner extends PlannerBase {
263
263
  .replaceEach((section) => {
264
264
  const heading = section.query('heading').text().trim();
265
265
  const withoutHeadings = mdq(section.text()).query('heading').replace('');
266
- const body = mdq(withoutHeadings).query('hr').replace('').trim();
266
+ const body = mdq(withoutHeadings).query('hr').replace('').toString().trim();
267
267
  if (body && !seenTitles.has(heading)) {
268
268
  seenTitles.add(heading);
269
269
  return section.text();
270
270
  }
271
271
  return '';
272
- });
272
+ })
273
+ .toString();
273
274
  }
274
275
  const trimmedTitles = new Set();
275
276
  for (const selector of ['section2', 'section3']) {
@@ -282,10 +283,11 @@ export class Planner extends PlannerBase {
282
283
  const count = section.query('blockquote').count();
283
284
  if (count <= 10)
284
285
  return section.text();
285
- const kept = mdq(section.text()).query('blockquote[10:]').replace('');
286
+ const kept = mdq(section.text()).query('blockquote[10:]').replace('').toString();
286
287
  trimmedTitles.add(heading);
287
288
  return `${kept.trimEnd()}\n> ... and ${count - 10} more discoveries\n`;
288
- });
289
+ })
290
+ .toString();
289
291
  }
290
292
  return result.trim() || null;
291
293
  }
@@ -360,7 +362,7 @@ export class Planner extends PlannerBase {
360
362
  if (this.scout && this.docsWeight > 0) {
361
363
  docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
362
364
  }
363
- let plannerResearch = mdq(research).query('code').replace('');
365
+ let plannerResearch = mdq(research).query('code').replace('').toString();
364
366
  plannerResearch = mdq(plannerResearch)
365
367
  .query('table')
366
368
  .replaceEach((table) => {
@@ -372,7 +374,8 @@ export class Planner extends PlannerBase {
372
374
  Type: r.Type || '',
373
375
  }));
374
376
  return jsonToTable(elementWithType, ['Element', 'Type']);
375
- });
377
+ })
378
+ .toString();
376
379
  const hasFocusedOverlay = hasFocusedSection(plannerResearch);
377
380
  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." : '';
378
381
  const featureFilter = feature ? `FOCUS FILTER: Only propose scenarios using elements relevant to "${feature}". Ignore all other elements.` : '';
@@ -32,8 +32,11 @@ export declare class Provider {
32
32
  finalizeConfig(config: Record<string, any>, options: any, telemetry: any): void;
33
33
  buildGenerateConfig(defaults: Record<string, any>, overrides: Record<string, any>, options: any): Record<string, any>;
34
34
  recordUsage(agentName: string, modelName: string, usage: any): void;
35
- raceWithIdleTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T>;
35
+ raceWithIdleTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, timeoutMs: number, busy?: {
36
+ tools: number;
37
+ }): Promise<T>;
36
38
  recoverFromContextLength(error: any, messages: ModelMessage[], options: any, retry: (messages: ModelMessage[], options: any) => Promise<any>): Promise<any>;
39
+ recoverWithPlainJson(messages: ModelMessage[], schema: any, model: any, options: any): Promise<any>;
37
40
  initLangfuse(): void;
38
41
  getTelemetry(options: any): any;
39
42
  startConversation(systemMessage: string, agentName?: string, model?: any): Conversation;