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,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,6 +24,7 @@ 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;
@@ -82,6 +84,7 @@ declare class Navigator implements Agent {
82
84
  args: any[];
83
85
  }>;
84
86
  totalAttempted: number;
87
+ judged?: Decision;
85
88
  }>;
86
89
  checkAlreadyVerified(aiResponse: string, actionResult: ActionResult): boolean;
87
90
  }
@@ -13,6 +13,7 @@ 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
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;
@@ -517,8 +520,7 @@ class Navigator {
517
520
  if (!value)
518
521
  return;
519
522
  const normalized = normalizeUrl(value);
520
- if (normalized)
521
- visitCounts.set(normalized, (visitCounts.get(normalized) || 0) + 1);
523
+ visitCounts.set(normalized, (visitCounts.get(normalized) || 0) + 1);
522
524
  };
523
525
  for (const transition of history) {
524
526
  countVisit(transition.toState.url);
@@ -527,7 +529,7 @@ class Navigator {
527
529
  if (opts?.visitedUrls) {
528
530
  for (const url of opts.visitedUrls) {
529
531
  const normalized = normalizeUrl(url);
530
- if (normalized && !visitCounts.has(normalized)) {
532
+ if (!visitCounts.has(normalized)) {
531
533
  visitCounts.set(normalized, 1);
532
534
  }
533
535
  }
@@ -619,6 +621,12 @@ class Navigator {
619
621
  tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
620
622
  return { verified: cachedVerification, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
621
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
+ }
622
630
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
623
631
  let experience = '';
624
632
  if (!actionResult.isInsideIframe) {
@@ -741,7 +749,8 @@ class Navigator {
741
749
  const inexpressible = !alreadyVerified && totalAttempted === 0;
742
750
  if (inexpressible) {
743
751
  tag('warning').log('No assertion could express this claim');
744
- 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 };
745
754
  }
746
755
  actionResult.addVerification(message, verified);
747
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;
@@ -480,6 +487,9 @@ export class Pilot {
480
487
  const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
481
488
  const actionsContext = this.formatActions(toolCalls);
482
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 '';
483
493
  const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
484
494
  const text = await this.sendToPilot(dedent `
485
495
  START URL: ${task.startUrl}
@@ -516,8 +526,12 @@ export class Pilot {
516
526
  let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
517
527
  if (image)
518
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));
519
533
  if (!undecided.length)
520
- return task.expected.map((text) => ({ text, status: decided(text) }));
534
+ return task.expected.map((text) => ({ text, status: settledByJudge.get(text) || decided(text) }));
521
535
  const schema = z.object({
522
536
  outcomes: z.array(z.object({
523
537
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
@@ -581,6 +595,9 @@ export class Pilot {
581
595
  response = await settle(userContent, this.provider.getAgenticModel('pilot'));
582
596
  const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome]));
583
597
  return task.expected.map((text) => {
598
+ const byJudge = settledByJudge.get(text);
599
+ if (byJudge)
600
+ return { text, status: byJudge };
584
601
  if (!undecided.includes(text))
585
602
  return { text, status: decided(text) };
586
603
  const outcome = judged.get(text);
@@ -589,6 +606,20 @@ export class Pilot {
589
606
  return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
590
607
  });
591
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
+ }
592
623
  formatExpectations(task) {
593
624
  const checked = task.getCheckedExpectations();
594
625
  const remaining = task.getRemainingExpectations();
@@ -605,6 +636,7 @@ export class Pilot {
605
636
  }
606
637
  this.conversation.addUserText(finalUserText);
607
638
  const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
639
+ const preparedCount = opts.task.preparedData.length;
608
640
  const result = await this.provider.invokeConversation(this.conversation, tools, {
609
641
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
610
642
  toolChoice: opts.tools ? 'auto' : 'none',
@@ -612,7 +644,7 @@ export class Pilot {
612
644
  stopWhen: () => opts.task.hasFinished,
613
645
  telemetry: { functionId },
614
646
  });
615
- const text = result?.response?.text || '';
647
+ const text = this.announcePreparedData(result?.response?.text || '', opts.task, preparedCount);
616
648
  const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => ({ url: e.output.url, content: e.output.content }));
617
649
  if (learned.length === 0)
618
650
  return text;
@@ -628,6 +660,23 @@ export class Pilot {
628
660
  </applied_experience>
629
661
  `;
630
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
+ }
631
680
  getExperienceToc() {
632
681
  const state = this.stateManager.getCurrentState();
633
682
  if (!state)
@@ -635,7 +684,7 @@ export class Pilot {
635
684
  return this.stateManager.getExperienceTracker().renderExperienceTocFor(ActionResult.fromState(state));
636
685
  }
637
686
  pickPlanningTools() {
638
- const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser } = this.agentTools ?? {};
687
+ const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser, judge } = this.agentTools ?? {};
639
688
  const planning = {};
640
689
  if (see)
641
690
  planning.see = see;
@@ -653,6 +702,8 @@ export class Pilot {
653
702
  planning.learnExperience = learnExperience;
654
703
  if (askUser)
655
704
  planning.askUser = askUser;
705
+ if (judge)
706
+ planning.judge = judge;
656
707
  withdrawVisionTools(planning);
657
708
  return planning;
658
709
  }
@@ -700,6 +751,7 @@ export class Pilot {
700
751
  });
701
752
  const stepText = `Precondition: created ${items.join(', ')}`;
702
753
  task.addStep(stepText);
754
+ task.preparedData.push(...items);
703
755
  tag('success').log(stepText);
704
756
  return { noted: true, prepared: true, created: result.created };
705
757
  },
@@ -1053,7 +1105,7 @@ export class Pilot {
1053
1105
  Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1054
1106
  visualClick, back, getVisitedStates, reset, stop, finish, record.
1055
1107
  Use tool names exactly as listed. Do not invent combined names or aliases.
1056
- 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.
1057
1109
 
1058
1110
  ${capabilityGroundingRule}
1059
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;
@@ -3,7 +3,7 @@ import { LangfuseSpanProcessor } from '@langfuse/otel';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { AsyncLocalStorage } from 'node:async_hooks';
5
5
  import dedent from 'dedent';
6
- import { APICallError, generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
6
+ import { APICallError, NoObjectGeneratedError, asSchema, extractJsonMiddleware, generateObject, generateText, isStepCount, parsePartialJson, registerTelemetry, tool, wrapLanguageModel } from 'ai';
7
7
  import { z } from 'zod';
8
8
  import { clearActivity, setActivity } from "../activity.js";
9
9
  import { configuredModels, modelName as getModelName } from '../config.js';
@@ -51,12 +51,12 @@ const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum
51
51
  function extractCachedTokens(usage) {
52
52
  return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
53
53
  }
54
- function abortAfterIdle(ms, cancel, controller) {
54
+ function abortAfterIdle(ms, cancel, controller, busy) {
55
55
  return new Promise((_, reject) => {
56
56
  const tick = () => {
57
57
  if (cancel.cancelled)
58
58
  return;
59
- if (executionController.isAwaitingInput()) {
59
+ if (executionController.isAwaitingInput() || busy.tools > 0) {
60
60
  setTimeout(tick, ms);
61
61
  return;
62
62
  }
@@ -78,7 +78,7 @@ export class Provider {
78
78
  otelSdk = null;
79
79
  defaultRetryOptions = {
80
80
  maxAttempts: 3,
81
- baseDelay: 10,
81
+ baseDelay: 1000,
82
82
  maxDelay: 10000,
83
83
  retryCondition: (error) => {
84
84
  return ((error.name === 'AI_APICallError' ||
@@ -231,12 +231,12 @@ export class Provider {
231
231
  cached: extractCachedTokens(usage),
232
232
  });
233
233
  }
234
- async raceWithIdleTimeout(fn, timeoutMs) {
234
+ async raceWithIdleTimeout(fn, timeoutMs, busy = { tools: 0 }) {
235
235
  const cancel = { cancelled: false };
236
236
  const controller = new AbortController();
237
237
  const combinedSignal = combinedAbortSignal(controller);
238
238
  try {
239
- return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, cancel, controller)]);
239
+ return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, cancel, controller, busy)]);
240
240
  }
241
241
  finally {
242
242
  cancel.cancelled = true;
@@ -249,6 +249,23 @@ export class Provider {
249
249
  tag('warning').log('Context length exceeded, retrying with reduced messages...');
250
250
  return retry(reduced.messages, { ...options, _contextRetryLevel: reduced.nextLevel });
251
251
  }
252
+ async recoverWithPlainJson(messages, schema, model, options) {
253
+ const target = asSchema(schema);
254
+ tag('warning').log(`${getModelName(model)} returned no structured output, asking for plain JSON instead`);
255
+ const instruction = dedent `
256
+ Respond with a single JSON object that matches this JSON Schema, and nothing else:
257
+ ${JSON.stringify(await target.jsonSchema)}
258
+ `;
259
+ const response = await this.chat([...messages, { role: 'user', content: instruction }], wrapLanguageModel({ model, middleware: extractJsonMiddleware() }), options);
260
+ const parsed = await parsePartialJson(response.text);
261
+ if (parsed.state !== 'successful-parse')
262
+ throw new AiError('No object generated: plain JSON fallback returned no parsable JSON');
263
+ const validated = await target.validate?.(parsed.value);
264
+ if (validated && !validated.success)
265
+ throw new AiError(`No object generated: plain JSON fallback did not match the schema: ${validated.error.message}`);
266
+ responseLog(parsed.value);
267
+ return { ...response, object: validated?.value ?? parsed.value };
268
+ }
252
269
  initLangfuse() {
253
270
  const { enabled, publicKey, secretKey, baseUrl } = this.config.langfuse || {};
254
271
  if (!enabled || !publicKey || !secretKey) {
@@ -369,6 +386,8 @@ export class Provider {
369
386
  }
370
387
  async generateWithTools(messages, model, tools, options = {}) {
371
388
  const modelName = getModelName(model);
389
+ const busy = { tools: 0 };
390
+ tools = withIdleExemption(tools, busy);
372
391
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
373
392
  promptLog(`Using model: ${modelName}`);
374
393
  let toolsWithCommentary = tools;
@@ -394,7 +413,7 @@ export class Provider {
394
413
  const onStepEnd = (step) => {
395
414
  stepMessages.push(...(step.response?.messages || []));
396
415
  };
397
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal, onStepEnd }), config.timeout || 30000).catch((error) => {
416
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal, onStepEnd }), config.timeout || 30000, busy).catch((error) => {
398
417
  if (stepMessages.length > 0) {
399
418
  tag('warning').log(`Keeping ${stepMessages.length} messages from tool steps that already ran before the failure`);
400
419
  executedStepMessages.push(...stepMessages);
@@ -476,6 +495,8 @@ export class Provider {
476
495
  if (Provider.isContextLengthError(error)) {
477
496
  return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateObject(m, schema, model, o));
478
497
  }
498
+ if (NoObjectGeneratedError.isInstance(error))
499
+ return this.recoverWithPlainJson(messages, schema, modelToUse, options);
479
500
  throw new AiError(error.message || error.toString());
480
501
  }
481
502
  }
@@ -695,4 +716,28 @@ function repairHarmonyChannel({ toolCall, tools }) {
695
716
  tag('warning').log(`Repaired tool name '${toolCall.toolName}' → 'commentary'`);
696
717
  return { ...toolCall, toolName: NARRATION_TOOL, input };
697
718
  }
719
+ function withIdleExemption(tools, busy) {
720
+ if (!tools)
721
+ return tools;
722
+ const wrapped = {};
723
+ for (const [name, definition] of Object.entries(tools)) {
724
+ if (typeof definition?.execute !== 'function') {
725
+ wrapped[name] = definition;
726
+ continue;
727
+ }
728
+ wrapped[name] = {
729
+ ...definition,
730
+ execute: async (...args) => {
731
+ busy.tools++;
732
+ try {
733
+ return await definition.execute(...args);
734
+ }
735
+ finally {
736
+ busy.tools--;
737
+ }
738
+ },
739
+ };
740
+ }
741
+ return wrapped;
742
+ }
698
743
  export { AiError, Provider as AIProvider };
@@ -94,7 +94,7 @@ export function WithDeepAnalysis(Base) {
94
94
  let updated;
95
95
  if (extQuery.count() > 0) {
96
96
  const existing = extQuery.text().trimEnd();
97
- updated = extQuery.replace(`${existing}\n\n${sectionMarkdown}\n`);
97
+ updated = extQuery.replace(`${existing}\n\n${sectionMarkdown}\n`).toString();
98
98
  }
99
99
  else {
100
100
  updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
@@ -481,7 +481,7 @@ export function WithDeepAnalysis(Base) {
481
481
  heading = mdq(sectionMarkdown).query('h2[0]');
482
482
  if (heading.count() === 0)
483
483
  return sectionMarkdown;
484
- return heading.replace(`${heading.text().trimEnd()}\n\n> Container: '${containerCss}'\n\n`);
484
+ return heading.replace(`${heading.text().trimEnd()}\n\n> Container: '${containerCss}'\n\n`).toString();
485
485
  }
486
486
  _deduplicateExpandedSections(sections) {
487
487
  const seen = new Set();
@@ -281,10 +281,10 @@ export function WithLocators(Base) {
281
281
  if (sectionQuery.count() === 0)
282
282
  sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
283
283
  if (newCss) {
284
- result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
284
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`).toString();
285
285
  }
286
286
  else {
287
- result.text = sectionQuery.query('blockquote[0]').replace('');
287
+ result.text = sectionQuery.query('blockquote[0]').replace('').toString();
288
288
  result.text = result.text.replace(`${FOCUSED_MARKER}\n`, '');
289
289
  }
290
290
  for (const loc of result.locators) {
@@ -56,7 +56,7 @@ export function WithPagination(Base) {
56
56
  sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
57
57
  if (sectionQuery.count() === 0)
58
58
  return;
59
- result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
59
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy).toString();
60
60
  }
61
61
  };
62
62
  }
@@ -50,10 +50,10 @@ export class ResearchResult {
50
50
  let sectionQuery = mdq(this.text).query(`section2(~"${escaped}")`);
51
51
  if (sectionQuery.count() === 0)
52
52
  sectionQuery = mdq(this.text).query(`section3(~"${escaped}")`);
53
- const updated = sectionQuery.query('table').replace(`${newTable.trimEnd()}\n`);
53
+ const updated = sectionQuery.query('table').replace(`${newTable.trimEnd()}\n`).toString();
54
54
  if (updated === this.text)
55
55
  return;
56
- section.rawMarkdown = mdq(section.rawMarkdown).query('table').replace(`${newTable.trimEnd()}\n`);
56
+ section.rawMarkdown = mdq(section.rawMarkdown).query('table').replace(`${newTable.trimEnd()}\n`).toString();
57
57
  this.text = updated;
58
58
  }
59
59
  cleanup() {
@@ -261,7 +261,7 @@ export class Researcher extends ResearcherBase {
261
261
  if (stateHash) {
262
262
  researchFile = saveResearch(researchState, result.text, combinedHtml);
263
263
  }
264
- const summaryText = mdq(result.text).query('section2(/^summary/)').query('paragraph[0]').text().trim();
264
+ const summaryText = mdq(result.text).query('section2(/^summary/i)').query('paragraph[0]').text().trim();
265
265
  const summaryLine = summaryText.split('\n')[0]?.trim().slice(0, 200);
266
266
  if (summaryLine)
267
267
  this.experienceTracker.updateSummary(this.actionResult, summaryLine);