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
@@ -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 };
@@ -18,6 +18,7 @@ import { formatHeadings } from "../utils/context-formatter.js";
18
18
  import { createDebug, tag } from "../utils/logger.js";
19
19
  import { loop } from "../utils/loop.js";
20
20
  import { RulesLoader } from "../utils/rules-loader.js";
21
+ import { isInternalStep } from "../utils/step-analyzer.js";
21
22
  import { toolExecutionLabel } from "./conversation.js";
22
23
  import { actionRule, locatorRule, sectionContextRule } from "./rules.js";
23
24
  import { TaskAgent } from "./task-agent.js";
@@ -70,6 +71,8 @@ export class Rerunner extends TaskAgent {
70
71
  const onStepStarted = (step) => {
71
72
  if (!step.toCode)
72
73
  return;
74
+ if (isInternalStep(step))
75
+ return;
73
76
  const code = highlight(step.toCode(), { language: 'javascript' });
74
77
  console.log(chalk.dim(` ${code}`));
75
78
  };
@@ -77,12 +80,16 @@ export class Rerunner extends TaskAgent {
77
80
  const task = this.getCurrentTask(testMap);
78
81
  if (!task || !step.toCode)
79
82
  return;
83
+ if (isInternalStep(step))
84
+ return;
80
85
  task.addStep(step.toCode(), step.duration, 'passed');
81
86
  };
82
87
  const onStepFailed = (step, error) => {
83
88
  const task = this.getCurrentTask(testMap);
84
89
  if (!task || !step.toCode)
85
90
  return;
91
+ if (isInternalStep(step))
92
+ return;
86
93
  task.addStep(step.toCode(), step.duration, 'failed', error?.message);
87
94
  console.log(chalk.red(` ${figureSet.cross} ${step.toCode()} — ${error?.message || 'failed'}`));
88
95
  };
@@ -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);
@@ -7,6 +7,7 @@ import type { StateManager } from '../state-manager.js';
7
7
  import { HooksRunner } from '../utils/hooks-runner.js';
8
8
  import type { AgentDeps, ToolDeps } from './agent.js';
9
9
  import { Historian } from './historian.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 { Quartermaster } from './quartermaster.js';
@@ -17,6 +18,7 @@ export declare abstract class TaskAgent {
17
18
  config: ExplorbotConfig;
18
19
  stateManager: StateManager;
19
20
  knowledgeTracker: KnowledgeTracker;
21
+ judge?: Judge;
20
22
  hooksRunner: HooksRunner;
21
23
  consecutiveFailures: number;
22
24
  consecutiveEmptyResults: number;
@@ -16,6 +16,7 @@ export class TaskAgent {
16
16
  config;
17
17
  stateManager;
18
18
  knowledgeTracker;
19
+ judge;
19
20
  hooksRunner;
20
21
  consecutiveFailures = 0;
21
22
  consecutiveEmptyResults = 0;
@@ -31,6 +32,7 @@ export class TaskAgent {
31
32
  this.config = deps.config;
32
33
  this.stateManager = deps.stateManager;
33
34
  this.knowledgeTracker = deps.knowledgeTracker;
35
+ this.judge = deps.judge;
34
36
  this.hooksRunner = new HooksRunner(deps.explorer, deps.config);
35
37
  }
36
38
  setHistorian(historian) {
@@ -40,7 +42,7 @@ export class TaskAgent {
40
42
  this._quartermaster = quartermaster;
41
43
  }
42
44
  get toolDeps() {
43
- return { explorer: this.explorer, stateManager: this.stateManager, ai: this.provider };
45
+ return { explorer: this.explorer, stateManager: this.stateManager, ai: this.provider, judge: this.judge };
44
46
  }
45
47
  getExperienceTracker() {
46
48
  return this.stateManager.getExperienceTracker();
@@ -112,8 +112,8 @@ export class Tester extends TaskAgent {
112
112
  let initialState = ActionResult.fromState(state);
113
113
  const currentUrl = state.fullUrl || state.url;
114
114
  let startOnCurrentPage = opts.startOnCurrentPage;
115
- if (isErrorPage(initialState) && !startOnCurrentPage && task.startUrl && normalizeUrl(currentUrl) !== normalizeUrl(task.startUrl)) {
116
- debugLog(`Recovering from error page at ${currentUrl} by navigating to ${task.startUrl}`);
115
+ if (!startOnCurrentPage && task.startUrl && normalizeUrl(currentUrl) !== normalizeUrl(task.startUrl)) {
116
+ debugLog(`Opening test start URL ${task.startUrl} before building context (was at ${currentUrl})`);
117
117
  try {
118
118
  await this.explorer.visit(task.startUrl);
119
119
  state = this.stateManager.getCurrentState();
@@ -123,7 +123,7 @@ export class Tester extends TaskAgent {
123
123
  startOnCurrentPage = true;
124
124
  }
125
125
  catch (error) {
126
- debugLog(`Could not recover from error page: ${compactErrorMessage(error)}`);
126
+ debugLog(`Could not open test start URL: ${compactErrorMessage(error)}`);
127
127
  }
128
128
  }
129
129
  if (isErrorPage(initialState)) {
@@ -135,13 +135,6 @@ export class Tester extends TaskAgent {
135
135
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'tester');
136
136
  conversation.markLastMessageCacheable();
137
137
  this.currentConversation = conversation;
138
- const scenarioBlock = this.buildScenarioBlock(task, initialState);
139
- conversation.addUserText(scenarioBlock);
140
- conversation.markLastMessageCacheable();
141
- conversation.protectPrefix(conversation.messages.length);
142
- const pageContext = await this.reinjectContextIfNeeded(1, initialState);
143
- if (pageContext)
144
- conversation.addUserText(pageContext);
145
138
  return await Observability.run(`test: ${task.scenario}`, {
146
139
  sessionId: task.sessionName,
147
140
  tags: ['tester'],
@@ -154,16 +147,15 @@ export class Tester extends TaskAgent {
154
147
  }
155
148
  async runTestSession(task, initialState, conversation, handlers, opts) {
156
149
  const { offFailedRequest } = handlers;
150
+ let plan = '';
157
151
  if (this.pilot) {
158
152
  try {
159
- const plan = await this.pilot.planTest(task, initialState);
153
+ await this.researcher.research(initialState).catch(this.skipResearch);
154
+ plan = await this.pilot.planTest(task, initialState);
160
155
  if (task.hasFinished) {
161
156
  offFailedRequest?.();
162
157
  return { success: task.isSuccessful };
163
158
  }
164
- if (plan) {
165
- conversation.addUserText(`Pilot's test plan:\n${plan}\n\nFollow this plan while executing the test.`);
166
- }
167
159
  }
168
160
  catch (err) {
169
161
  const message = err instanceof Error ? err.message : String(err);
@@ -183,7 +175,7 @@ export class Tester extends TaskAgent {
183
175
  }
184
176
  if (opts.startOnCurrentPage)
185
177
  debugLog(`Starting on the page already open at ${task.startUrl}`);
186
- if (!opts.startOnCurrentPage) {
178
+ if (!opts.startOnCurrentPage || task.preparedData.length > 0) {
187
179
  debugLog(`Navigating to ${task.startUrl}`);
188
180
  try {
189
181
  await this.explorer.visit(task.startUrl);
@@ -208,6 +200,15 @@ export class Tester extends TaskAgent {
208
200
  }
209
201
  const currentUrl = startState?.url || task.startUrl || '';
210
202
  await this.hooksRunner.runBeforeHook('tester', currentUrl);
203
+ const testerState = this.getCurrentState();
204
+ conversation.addUserText(this.buildScenarioBlock(task, testerState));
205
+ conversation.markLastMessageCacheable();
206
+ conversation.protectPrefix(conversation.messages.length);
207
+ const pageContext = await this.reinjectContextIfNeeded(1, testerState);
208
+ if (pageContext)
209
+ conversation.addUserText(pageContext);
210
+ if (plan)
211
+ conversation.addUserText(`Pilot's test plan:\n${plan}\n\nFollow this plan while executing the test.`);
211
212
  const offStateChange = this.stateManager.onStateChange((event) => {
212
213
  if (task.hasFinished)
213
214
  return;
@@ -836,6 +837,9 @@ export class Tester extends TaskAgent {
836
837
  `;
837
838
  }
838
839
  buildDeletionScope(task) {
840
+ if (task.preparedData.length > 0) {
841
+ return `When deleting items, ONLY delete the data prepared for this test: ${task.preparedData.join('; ')}.`;
842
+ }
839
843
  const deletableItems = this.getDeletableSessionNames(task);
840
844
  if (deletableItems.length > 0) {
841
845
  return `When deleting items, ONLY delete items whose title contains one of these session names: ${deletableItems.join(', ')}. These were created by previous tests.`;
@@ -3,6 +3,7 @@ import type { ExecutedStep } from '../action.js';
3
3
  import { type ExperienceTracker } from '../experience-tracker.js';
4
4
  import { type Task } from '../test-plan.js';
5
5
  import type { ToolDeps } from './agent.js';
6
+ import { type Judge } from './judge.js';
6
7
  import { Navigator } from './navigator.js';
7
8
  import { Researcher } from './researcher.js';
8
9
  interface AgentToolDeps extends ToolDeps {
@@ -12,7 +13,7 @@ interface AgentToolDeps extends ToolDeps {
12
13
  withExperience?: boolean;
13
14
  }
14
15
  export declare const ASSERTION_TOOLS: readonly ["verify"];
15
- export declare function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task): {
16
+ export declare function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDeps, task: Task): {
16
17
  click: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
17
18
  commands: any;
18
19
  explanation: any;
@@ -55,7 +56,7 @@ export declare function createLearnExperienceTool({ getExperienceTracker, getSta
55
56
  } | {
56
57
  error: string;
57
58
  }, import("@ai-sdk/provider-utils").Context>>;
58
- export declare function createAgentTools({ explorer, stateManager, ai, researcher, navigator, supervisor, withExperience }: AgentToolDeps): any;
59
+ export declare function createAgentTools({ explorer, stateManager, ai, judge, researcher, navigator, supervisor, withExperience }: AgentToolDeps): any;
59
60
  export declare function commitNote(activeNote: any, result: TestResult, toolResult: any, action: any): Promise<void>;
60
61
  export declare function successToolResult(action: string, data?: Record<string, any>, source?: {
61
62
  playwrightGroupId?: string | null;
@@ -64,7 +65,7 @@ export declare function successToolResult(action: string, data?: Record<string,
64
65
  export declare function isMajorPageChange(pageDiff: PageDiff): boolean;
65
66
  export declare function hasFailedRequest(pageDiff: PageDiff): boolean;
66
67
  export declare function formatExecutedSteps(steps: ExecutedStep[], requestedCount?: number): string;
67
- export declare function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null): Promise<Record<string, any>>;
68
+ export declare function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null, judge?: Judge, intent?: string): Promise<Record<string, any>>;
68
69
  export declare function withdrawVisionTools(tools: Record<string, any>): void;
69
70
  export declare function clickFailureSuggestion(attempts: Array<{
70
71
  error?: string;
@@ -13,11 +13,13 @@ import { createDebug, tag } from '../utils/logger.js';
13
13
  import { pause } from '../utils/loop.js';
14
14
  import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
15
15
  import { WebElement } from "../utils/web-element.js";
16
+ import { createJudgeTool } from "./judge-tool.js";
17
+ import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
16
18
  import { sectionContextRule } from "./rules.js";
17
19
  import { isInteractive } from "./task-agent.js";
18
20
  const debugLog = createDebug('explorbot:tools');
19
21
  export const ASSERTION_TOOLS = ['verify'];
20
- export function createCodeceptJSTools({ explorer, stateManager }, task) {
22
+ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
21
23
  return {
22
24
  click: tool({
23
25
  description: dedent `
@@ -125,7 +127,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
125
127
  ...toolResult,
126
128
  attempts,
127
129
  suggestion,
128
- }, ambiguityError || action.lastError);
130
+ }, ambiguityError || action.lastError, judge, explanation);
129
131
  },
130
132
  }),
131
133
  hover: tool({
@@ -189,7 +191,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
189
191
  ...toolResult,
190
192
  attempts,
191
193
  suggestion: 'Use xpathCheck() to locate the row/card/tree node, or visualClick() if the hover target is only visually identifiable.',
192
- }, action.lastError);
194
+ }, action.lastError, judge, explanation);
193
195
  },
194
196
  }),
195
197
  pressKey: tool({
@@ -312,7 +314,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
312
314
  - Performing multiple form actions in a single batch
313
315
  - Complex interactions requiring sequential commands
314
316
  - Reaching items further down a list (I.scrollTo)
315
- - Reloading the page to prove a change outlived it (I.reloadPage)
317
+ - Reloading the page to prove a change outlived it (I.refreshPage)
316
318
 
317
319
  Example - filling a form with context (PREFERRED):
318
320
  I.fillField('Username', 'John', '.login-form')
@@ -363,7 +365,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
363
365
  code: codeBlock,
364
366
  attempts: action.executedSteps,
365
367
  suggestion: formSuggestion,
366
- }, action.lastError);
368
+ }, action.lastError, judge, explanation);
367
369
  }
368
370
  if (!hasObservablePageChange(toolResult)) {
369
371
  activeNote.commit(TestResult.FAILED);
@@ -498,7 +500,7 @@ export function createLearnExperienceTool({ getExperienceTracker, getState }) {
498
500
  },
499
501
  });
500
502
  }
501
- export function createAgentTools({ explorer, stateManager, ai, researcher, navigator, supervisor, withExperience }) {
503
+ export function createAgentTools({ explorer, stateManager, ai, judge, researcher, navigator, supervisor, withExperience }) {
502
504
  const tools = {
503
505
  see: tool({
504
506
  description: dedent `
@@ -619,6 +621,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
619
621
  }, { assertionSteps: result.assertionSteps });
620
622
  }
621
623
  if (result.inexpressible) {
624
+ if (result.judged?.approved) {
625
+ return failedToolResult('verify', `No assertion could express this claim, but the page appears to confirm it: ${assertion}`, {
626
+ inexpressible: true,
627
+ suggestion: 'This is a judgement about the page, not an assertion that ran in the browser — a hint, not proof. Restate the claim in terms of what is visible or of a control state to get a real assertion.',
628
+ });
629
+ }
622
630
  return failedToolResult('verify', `No assertion could express this claim: ${assertion}`, {
623
631
  inexpressible: true,
624
632
  suggestion: 'This is not evidence the page is wrong — the claim could not be turned into an assertion. Restate it in terms of what is visible or of a control state, or check it with see().',
@@ -994,6 +1002,19 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
994
1002
  },
995
1003
  });
996
1004
  }
1005
+ const buildJudgeState = async () => {
1006
+ const activeTest = explorer.activeTest;
1007
+ const state = stateManager.getCurrentState();
1008
+ const result = state ? ActionResult.fromState(state) : null;
1009
+ return {
1010
+ task: activeTest?.scenario || '',
1011
+ page: cap(result?.getCompactARIA(), JUDGE_PAGE_CAP),
1012
+ recentActions: Object.values(activeTest?.steps || {})
1013
+ .slice(-JUDGE_RECENT_ACTIONS_LIMIT)
1014
+ .map((step) => step.text),
1015
+ };
1016
+ };
1017
+ Object.assign(tools, createJudgeTool({ explorer, stateManager, ai, judge }, buildJudgeState));
997
1018
  withdrawVisionTools(tools);
998
1019
  return tools;
999
1020
  }
@@ -1003,6 +1024,7 @@ const NAVIGATED_SUGGESTION = 'The action left the page. Elements are never compa
1003
1024
  const ARIA_OUTPUT_CAP = 4000;
1004
1025
  const HTML_OUTPUT_CAP = 6000;
1005
1026
  const ANALYSIS_OUTPUT_CAP = 2000;
1027
+ const JUDGE_RECENT_ACTIONS_LIMIT = 8;
1006
1028
  function cap(text, max) {
1007
1029
  if (!text)
1008
1030
  return '';
@@ -1115,7 +1137,7 @@ function hasObservablePageChange(data) {
1115
1137
  return true;
1116
1138
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1117
1139
  }
1118
- export async function failedToolResult(action, message, data, error) {
1140
+ export async function failedToolResult(action, message, data, error, judge, intent) {
1119
1141
  const result = { success: false, action, message, ...data };
1120
1142
  if (data?.pageDiff) {
1121
1143
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
@@ -1126,6 +1148,12 @@ export async function failedToolResult(action, message, data, error) {
1126
1148
  result.suggestion = getMultipleElementsSuggestion();
1127
1149
  result.multipleElementsDetected = true;
1128
1150
  result.elements = formatElementList(matched);
1151
+ const labels = (matched || []).map((element) => `${element.text || 'no text'} — ${element.html}`);
1152
+ const pick = await judge?.decide('Which listed element does the intent name?', [...labels, UNDECIDED], { intent });
1153
+ if (!pick?.value)
1154
+ return result;
1155
+ const index = labels.indexOf(pick.value) + 1;
1156
+ result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
1129
1157
  return result;
1130
1158
  }
1131
1159
  const notFoundSuggestion = getNotFoundSuggestion(message);
@@ -162,7 +162,8 @@ export class RequestResult {
162
162
  if (Array.isArray(data))
163
163
  return {};
164
164
  const id = data.id ?? data._id ?? data.uuid;
165
- const title = data.name ?? data.title ?? data.label;
165
+ const attributes = data.attributes || {};
166
+ const title = data.name ?? data.title ?? data.label ?? attributes.name ?? attributes.title ?? attributes.label;
166
167
  const result = {};
167
168
  if (id !== undefined)
168
169
  result.id = id;
@@ -1,3 +1,4 @@
1
+ import { isSameHostFamily } from '../utils/url-matcher.js';
1
2
  import { RequestResult, generateRequestId } from "./request-result.js";
2
3
  const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
3
4
  const JSON_CONTENT_TYPES = /application\/json|application\/.*\+json/i;
@@ -33,7 +34,7 @@ export class XhrCapture {
33
34
  return;
34
35
  const method = request.method();
35
36
  const url = request.url();
36
- if (!url.startsWith(this.baseOrigin))
37
+ if (!isSameHostFamily(url, this.baseOrigin))
37
38
  return;
38
39
  const status = response.status();
39
40
  if (status >= 400) {
@@ -24,6 +24,7 @@ export interface CommandAutocomplete {
24
24
  replaceTo: number;
25
25
  visible: boolean;
26
26
  argumentHint?: string;
27
+ completesArgument?: boolean;
27
28
  }
28
29
  export declare class CommandHandler implements InputManager {
29
30
  explorBot: ExplorBot;
@@ -207,6 +207,28 @@ export class CommandHandler {
207
207
  const parsed = parseCommand(query);
208
208
  const exactCommand = parsed ? this.findCommand(parsed.name) : undefined;
209
209
  const hasArguments = commandEnd !== -1 && input.slice(commandEnd + 1).trim().length > 0;
210
+ const argumentHint = !insideCommand && exactCommand && !hasArguments && exactCommand.options.length > 0 ? exactCommand.options.map((option) => option.flags).join(' ') : undefined;
211
+ if (!insideCommand && exactCommand) {
212
+ const argumentQuery = input.slice(commandEnd + 1).trim();
213
+ const argumentEntries = exactCommand
214
+ .completeArguments()
215
+ .filter((completion) => completion.value !== argumentQuery)
216
+ .map((completion) => ({
217
+ aliases: [],
218
+ canonical: completion.value,
219
+ description: '',
220
+ value: completion.display || completion.value,
221
+ }));
222
+ const argumentSuggestions = this.rankSuggestions(argumentQuery, argumentEntries);
223
+ return {
224
+ suggestions: argumentSuggestions,
225
+ replaceFrom: commandEnd + 1,
226
+ replaceTo: input.length,
227
+ visible: argumentSuggestions.length > 0,
228
+ argumentHint,
229
+ completesArgument: true,
230
+ };
231
+ }
210
232
  const commandEntries = this.getSlashCommandEntries();
211
233
  let suggestions = [];
212
234
  if (insideCommand) {
@@ -222,9 +244,8 @@ export class CommandHandler {
222
244
  suggestions = this.rankSuggestions(query, commandEntries);
223
245
  }
224
246
  }
225
- const argumentHint = !insideCommand && exactCommand && !hasArguments && exactCommand.options.length > 0 ? exactCommand.options.map((option) => option.flags).join(' ') : undefined;
226
247
  return {
227
- suggestions,
248
+ suggestions: suggestions.map((suggestion) => ({ ...suggestion, value: `${suggestion.value} ` })),
228
249
  replaceFrom: 0,
229
250
  replaceTo,
230
251
  visible: insideCommand && suggestions.length > 0,
@@ -305,7 +326,7 @@ export class CommandHandler {
305
326
  rankSuggestions(query, entries) {
306
327
  if (!query) {
307
328
  return entries.slice(0, 20).map((entry) => ({
308
- value: entry.value,
329
+ value: entry.canonical,
309
330
  display: entry.value,
310
331
  description: entry.description,
311
332
  argumentHint: entry.argumentHint,
@@ -18,9 +18,14 @@ export declare abstract class BaseCommand<T = ExplorBot> {
18
18
  constructor(explorBot: T);
19
19
  abstract execute(args: string): Promise<void>;
20
20
  matches(commandName: string): boolean;
21
+ completeArguments(): ArgumentCompletion[];
21
22
  printSuggestions(): void;
22
23
  parseArgs(args: string): {
23
24
  opts: Record<string, string | boolean>;
24
25
  args: string[];
25
26
  };
26
27
  }
28
+ export interface ArgumentCompletion {
29
+ value: string;
30
+ display?: string;
31
+ }
@@ -15,6 +15,9 @@ export class BaseCommand {
15
15
  matches(commandName) {
16
16
  return this.name === commandName || this.aliases.includes(commandName);
17
17
  }
18
+ completeArguments() {
19
+ return [];
20
+ }
18
21
  printSuggestions() {
19
22
  if (this.suggestions.length === 0)
20
23
  return;
@@ -1,5 +1,5 @@
1
1
  import { type Plan, type Test } from '../test-plan.js';
2
- import { BaseCommand, type Suggestion } from './base-command.js';
2
+ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
3
3
  export declare const DEADLINE_RESERVE_MS: number;
4
4
  export declare const DEADLINE_TEST_ALLOWANCE_MS: number;
5
5
  export declare class ExploreCommand extends BaseCommand {
@@ -22,6 +22,7 @@ export declare class ExploreCommand extends BaseCommand {
22
22
  priorityFilter?: Set<string>;
23
23
  getCurrentPageUrl(): string | undefined;
24
24
  execute(args: string): Promise<void>;
25
+ completeArguments(): ArgumentCompletion[];
25
26
  originLabel(test: Test): string;
26
27
  printPreview(label: string, tests: Test[]): void;
27
28
  runFreshMode(mainUrl: string | undefined, feature: string | undefined, styles?: string[]): Promise<void>;