explorbot 0.2.5 → 0.3.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 (82) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/driller.js +6 -2
  17. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  18. package/dist/src/ai/fisherman-tools.js +39 -0
  19. package/dist/src/ai/fisherman.js +2 -1
  20. package/dist/src/ai/navigator.d.ts +2 -1
  21. package/dist/src/ai/navigator.js +5 -9
  22. package/dist/src/ai/pilot.js +39 -22
  23. package/dist/src/ai/planner/subpages.js +2 -16
  24. package/dist/src/ai/planner.js +1 -1
  25. package/dist/src/ai/provider.js +16 -1
  26. package/dist/src/ai/researcher/cache.d.ts +8 -3
  27. package/dist/src/ai/researcher/cache.js +13 -8
  28. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  29. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  30. package/dist/src/ai/researcher.js +4 -3
  31. package/dist/src/ai/rules.js +1 -5
  32. package/dist/src/ai/tester.d.ts +1 -1
  33. package/dist/src/ai/tester.js +13 -22
  34. package/dist/src/ai/tools.d.ts +8 -5
  35. package/dist/src/ai/tools.js +79 -56
  36. package/dist/src/commands/init-command.js +13 -20
  37. package/dist/src/config.js +3 -1
  38. package/dist/src/experience-tracker.d.ts +2 -0
  39. package/dist/src/experience-tracker.js +12 -0
  40. package/dist/src/explorbot.js +1 -1
  41. package/dist/src/playwright-recorder.js +6 -12
  42. package/dist/src/test-plan.d.ts +8 -0
  43. package/dist/src/test-plan.js +11 -0
  44. package/dist/src/utils/html-diff.d.ts +5 -0
  45. package/dist/src/utils/html-diff.js +65 -6
  46. package/dist/src/utils/strings.d.ts +2 -0
  47. package/dist/src/utils/strings.js +32 -0
  48. package/dist/src/utils/url-matcher.d.ts +1 -0
  49. package/dist/src/utils/url-matcher.js +31 -2
  50. package/docs/basics/getting-started.md +33 -10
  51. package/docs/basics/providers.md +6 -4
  52. package/docs/contributing/npm-package.md +73 -4
  53. package/models.json +4 -4
  54. package/package.json +6 -2
  55. package/src/action-result.ts +61 -16
  56. package/src/action.ts +51 -17
  57. package/src/ai/captain/web-mode.ts +1 -2
  58. package/src/ai/captain.ts +9 -1
  59. package/src/ai/driller.ts +6 -2
  60. package/src/ai/fisherman-tools.ts +35 -0
  61. package/src/ai/fisherman.ts +2 -1
  62. package/src/ai/navigator.ts +6 -10
  63. package/src/ai/pilot.ts +41 -24
  64. package/src/ai/planner/subpages.ts +2 -13
  65. package/src/ai/planner.ts +1 -1
  66. package/src/ai/provider.ts +17 -1
  67. package/src/ai/researcher/cache.ts +17 -9
  68. package/src/ai/researcher/deep-analysis.ts +1 -1
  69. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  70. package/src/ai/researcher.ts +4 -3
  71. package/src/ai/rules.ts +1 -5
  72. package/src/ai/tester.ts +13 -22
  73. package/src/ai/tools.ts +84 -60
  74. package/src/commands/init-command.ts +14 -20
  75. package/src/config.ts +2 -1
  76. package/src/experience-tracker.ts +13 -0
  77. package/src/explorbot.ts +1 -1
  78. package/src/playwright-recorder.ts +6 -11
  79. package/src/test-plan.ts +18 -0
  80. package/src/utils/html-diff.ts +72 -7
  81. package/src/utils/strings.ts +36 -0
  82. package/src/utils/url-matcher.ts +27 -2
@@ -17,6 +17,8 @@ const debugLog = createDebug('explorbot:action');
17
17
  const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
18
18
  const DEFAULT_ACTION_TIMEOUT = 3000;
19
19
  const DEFAULT_PAGE_TIMEOUT = 3000;
20
+ const MAX_NETWORK_CALLS = 10;
21
+ const IMPORTANT_LOG_LEVELS = new Set(['info', 'error', 'warning', 'warn']);
20
22
  class Action {
21
23
  actor;
22
24
  stateManager;
@@ -32,6 +34,8 @@ class Action {
32
34
  recorder;
33
35
  recovery;
34
36
  mainDocumentStatus = undefined;
37
+ networkRequests = [];
38
+ baseOrigin;
35
39
  constructor(actor, stateManager, recorder, recovery) {
36
40
  this.actor = actor;
37
41
  this.stateManager = stateManager;
@@ -39,6 +43,7 @@ class Action {
39
43
  this.playwrightHelper = container.helpers('Playwright');
40
44
  this.recorder = recorder;
41
45
  this.recovery = recovery || ((fn) => fn());
46
+ this.baseOrigin = URL.parse(this.config.playwright?.url || '')?.origin || '';
42
47
  }
43
48
  async saveScreenshot() {
44
49
  const currentState = this.stateManager.getCurrentState();
@@ -117,9 +122,7 @@ class Action {
117
122
  const logPath = join(statesDir, logFile);
118
123
  const formattedLogs = browserLogs.map((log) => {
119
124
  const logTimestamp = new Date().toISOString();
120
- const level = (log.type || log.level || 'LOG').toUpperCase();
121
- const message = log.text || log.message || String(log);
122
- return `[${logTimestamp}] ${level}: ${message}`;
125
+ return `[${logTimestamp}] ${log.type.toUpperCase()}: ${log.text}`;
123
126
  });
124
127
  fs.writeFileSync(logPath, `${formattedLogs.join('\n')}\n`, 'utf8');
125
128
  debugLog('Page:', { url, title, size: html.length, html: html.substring(0, 100) });
@@ -142,12 +145,15 @@ class Action {
142
145
  fs.writeFileSync(ariaPath, ariaSnapshot, 'utf8');
143
146
  ariaSnapshotFile = ariaFileName;
144
147
  }
148
+ const networkRequests = this.networkRequests;
149
+ this.networkRequests = [];
145
150
  const result = new ActionResult({
146
151
  html,
147
152
  title,
148
153
  httpStatus: await this.captureMainDocumentStatus(),
149
154
  url,
150
155
  browserLogs,
156
+ networkRequests,
151
157
  htmlFile,
152
158
  logFile,
153
159
  screenshotFile,
@@ -192,27 +198,52 @@ class Action {
192
198
  return undefined;
193
199
  }
194
200
  }
195
- captureMainDocumentResponse() {
201
+ captureResponses() {
196
202
  const page = this.playwrightHelper.page;
197
203
  if (!page?.on || !page?.off)
198
204
  return () => { };
199
205
  this.mainDocumentStatus = undefined;
206
+ this.networkRequests = [];
200
207
  const handler = (response) => {
201
208
  const request = response.request();
202
- if (request.resourceType() !== 'document')
203
- return;
204
- if (response.frame() !== page.mainFrame())
205
- return;
206
209
  const status = response.status();
207
210
  if (typeof status !== 'number')
208
211
  return;
209
212
  if (status <= 0)
210
213
  return;
211
- this.mainDocumentStatus = status;
214
+ if (request.resourceType() === 'document') {
215
+ if (response.frame() !== page.mainFrame())
216
+ return;
217
+ this.mainDocumentStatus = status;
218
+ return;
219
+ }
220
+ this.recordNetworkCall(request, status);
212
221
  };
213
222
  page.on('response', handler);
214
223
  return () => page.off('response', handler);
215
224
  }
225
+ recordNetworkCall(request, status) {
226
+ const resourceType = request.resourceType();
227
+ if (resourceType !== 'xhr' && resourceType !== 'fetch')
228
+ return;
229
+ const url = URL.parse(request.url());
230
+ if (!url)
231
+ return;
232
+ if (url.origin !== this.baseOrigin)
233
+ return;
234
+ const call = { method: request.method(), path: url.pathname, status };
235
+ if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status))
236
+ return;
237
+ if (this.networkRequests.length >= MAX_NETWORK_CALLS) {
238
+ if (status < 400)
239
+ return;
240
+ const succeeded = this.networkRequests.findIndex((r) => r.status < 400);
241
+ if (succeeded === -1)
242
+ return;
243
+ this.networkRequests.splice(succeeded, 1);
244
+ }
245
+ this.networkRequests.push(call);
246
+ }
216
247
  /**
217
248
  * Capture HTML snapshots of all iframes on the page
218
249
  */
@@ -249,12 +280,7 @@ class Action {
249
280
  async captureBrowserLogs() {
250
281
  try {
251
282
  const logs = await this.actor.grabBrowserLogs();
252
- // Filter for important logs (info, error, warning)
253
- const importantLogs = logs.filter((log) => {
254
- const level = log.type || log.level;
255
- return ['info', 'error', 'warning', 'warn'].includes(level);
256
- });
257
- return importantLogs;
283
+ return logs.map(toBrowserLog).filter((log) => IMPORTANT_LOG_LEVELS.has(log.type));
258
284
  }
259
285
  catch (error) {
260
286
  debugLog('Failed to capture browser logs:', error);
@@ -270,7 +296,7 @@ class Action {
270
296
  const stepListener = attachStepLogger(executedSteps, assertionSteps);
271
297
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
272
298
  this.playwrightGroupId = groupId;
273
- const detachMainDocumentResponse = this.captureMainDocumentResponse();
299
+ const detachResponses = this.captureResponses();
274
300
  const activeSpan = Observability.getSpan();
275
301
  const tracer = trace.getTracer('ai');
276
302
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
@@ -314,7 +340,7 @@ class Action {
314
340
  }
315
341
  finally {
316
342
  this.restorePageTimeout();
317
- detachMainDocumentResponse();
343
+ detachResponses();
318
344
  if (groupId)
319
345
  await this.recorder.endAction();
320
346
  detachStepLogger(stepListener);
@@ -393,6 +419,11 @@ async function captureHtml(page, frame, actor) {
393
419
  return actor.grabSource();
394
420
  throw new Error('Playwright page is unavailable for HTML capture');
395
421
  }
422
+ function toBrowserLog(log) {
423
+ const type = typeof log.type === 'function' ? log.type() : log.type || log.level || 'log';
424
+ const text = typeof log.text === 'function' ? log.text() : log.text || log.message || String(log);
425
+ return { type, text: text.replace(/\s+/g, ' ').trim() };
426
+ }
396
427
  async function captureTitle(page, actor) {
397
428
  if (page?.title)
398
429
  return page.title();
@@ -15,7 +15,7 @@ export function WithWebMode(Base) {
15
15
  researcher: ctx.explorBot.agentResearcher(),
16
16
  navigator: ctx.explorBot.agentNavigator(),
17
17
  });
18
- const { see, context, visualClick, learnExperience } = agentTools;
18
+ const { see, context, visualClick } = agentTools;
19
19
  const tools = {
20
20
  navigate: tool({
21
21
  description: 'Navigate to a URL or page description using AI-powered navigation.',
@@ -115,7 +115,6 @@ export function WithWebMode(Base) {
115
115
  }),
116
116
  ...codeceptTools,
117
117
  context,
118
- learnExperience,
119
118
  };
120
119
  if (see)
121
120
  tools.see = see;
@@ -59,6 +59,16 @@ export declare class Captain extends CaptainBase implements Agent {
59
59
  planSummary(): string;
60
60
  reinjectContextIfNeeded(conversation: Conversation, currentState: WebPageState): Promise<void>;
61
61
  coreTools(task: Task, onDone: (summary: string) => void): {
62
+ learnExperience: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
63
+ fileTag: any;
64
+ sectionIndex: any;
65
+ }, {
66
+ title: string;
67
+ url: string;
68
+ content: string;
69
+ } | {
70
+ error: string;
71
+ }, import("@ai-sdk/provider-utils").Context>>;
62
72
  done: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
63
73
  summary: string;
64
74
  details?: string;
@@ -91,6 +101,16 @@ export declare class Captain extends CaptainBase implements Agent {
91
101
  }, import("@ai-sdk/provider-utils").Context>>;
92
102
  };
93
103
  tools(task: Task, onDone: (summary: string) => void): Promise<{
104
+ learnExperience: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
105
+ fileTag: any;
106
+ sectionIndex: any;
107
+ }, {
108
+ title: string;
109
+ url: string;
110
+ content: string;
111
+ } | {
112
+ error: string;
113
+ }, import("@ai-sdk/provider-utils").Context>>;
94
114
  done: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
95
115
  summary: string;
96
116
  details?: string;
@@ -15,7 +15,7 @@ import { WithWebMode } from "./captain/web-mode.js";
15
15
  import { toolExecutionLabel } from './conversation.js';
16
16
  import { Researcher } from "./researcher.js";
17
17
  import { TaskAgent } from "./task-agent.js";
18
- import { withdrawVisionTools } from "./tools.js";
18
+ import { createLearnExperienceTool, withdrawVisionTools } from "./tools.js";
19
19
  const MAX_STEPS = 15;
20
20
  const CaptainBase = WithTestMode(WithWebMode(WithIdleMode(TaskAgent)));
21
21
  export class Captain extends CaptainBase {
@@ -216,6 +216,15 @@ export class Captain extends CaptainBase {
216
216
  }
217
217
  coreTools(task, onDone) {
218
218
  return {
219
+ learnExperience: createLearnExperienceTool({
220
+ getExperienceTracker: () => this.getExperienceTracker(),
221
+ getState: () => {
222
+ const state = this.explorBot.stateManager().getCurrentState();
223
+ if (!state)
224
+ return null;
225
+ return ActionResult.fromState(state);
226
+ },
227
+ }),
219
228
  done: tool({
220
229
  description: 'Call when the user request is fulfilled.',
221
230
  inputSchema: z.object({
@@ -14,7 +14,7 @@ import { eidxInContainer } from "../utils/web-eidx.js";
14
14
  import { WebElement } from "../utils/web-element.js";
15
15
  import { drillLocatorRule } from "./rules.js";
16
16
  import { TaskAgent, isInteractive } from "./task-agent.js";
17
- import { createCodeceptJSTools } from "./tools.js";
17
+ import { createCodeceptJSTools, createLearnExperienceTool } from "./tools.js";
18
18
  const debugLog = createDebug('explorbot:driller');
19
19
  export class Driller extends TaskAgent {
20
20
  ACTION_TOOLS = ['click', 'pressKey', 'form'];
@@ -234,7 +234,11 @@ export class Driller extends TaskAgent {
234
234
  conversation.addUserText(await this.buildComponentPrompt(originalState, component));
235
235
  let finished = false;
236
236
  const actionTools = this.createVerifiedActionTools(createCodeceptJSTools(this.toolDeps, test), component);
237
- const tools = { ...actionTools, ...this.createDrillFlowTools(originalState, test, interactive) };
237
+ const learnExperience = createLearnExperienceTool({
238
+ getExperienceTracker: () => this.getExperienceTracker(),
239
+ getState: () => ActionResult.fromState(this.stateManager.getCurrentState() || originalState),
240
+ });
241
+ const tools = { ...actionTools, learnExperience, ...this.createDrillFlowTools(originalState, test, interactive) };
238
242
  await loop(async ({ stop, iteration }) => {
239
243
  debugLog(`Drilling component ${component.name}, iteration ${iteration}`);
240
244
  setActivity(`${this.emoji} Drilling ${component.name}...`, 'action');
@@ -9,18 +9,49 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
9
9
  method: any;
10
10
  path: any;
11
11
  }, {
12
+ source: string;
13
+ method: any;
14
+ path: any;
15
+ definition: string;
16
+ rejectedCapture: {
17
+ status: number;
18
+ requestBody: any;
19
+ };
20
+ usable?: undefined;
21
+ rejectedRequestBody?: undefined;
22
+ status?: undefined;
23
+ requestBody?: undefined;
24
+ error?: undefined;
25
+ } | {
26
+ source: string;
27
+ method: any;
28
+ path: any;
29
+ usable: boolean;
30
+ rejectedRequestBody: any;
31
+ status: number;
32
+ definition?: undefined;
33
+ rejectedCapture?: undefined;
34
+ requestBody?: undefined;
35
+ error?: undefined;
36
+ } | {
12
37
  source: string;
13
38
  method: string;
14
39
  path: string;
15
40
  status: number;
16
41
  requestBody: any;
17
42
  definition?: undefined;
43
+ rejectedCapture?: undefined;
44
+ usable?: undefined;
45
+ rejectedRequestBody?: undefined;
18
46
  error?: undefined;
19
47
  } | {
20
48
  source: string;
21
49
  definition: string;
22
50
  method?: undefined;
23
51
  path?: undefined;
52
+ rejectedCapture?: undefined;
53
+ usable?: undefined;
54
+ rejectedRequestBody?: undefined;
24
55
  status?: undefined;
25
56
  requestBody?: undefined;
26
57
  error?: undefined;
@@ -29,20 +60,25 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
29
60
  error: any;
30
61
  method?: undefined;
31
62
  path?: undefined;
63
+ definition?: undefined;
64
+ rejectedCapture?: undefined;
65
+ usable?: undefined;
66
+ rejectedRequestBody?: undefined;
32
67
  status?: undefined;
33
68
  requestBody?: undefined;
34
- definition?: undefined;
35
69
  }, import("@ai-sdk/provider-utils").Context>>;
36
70
  request: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<any, {
37
71
  success: boolean;
38
72
  error: string;
39
73
  status?: undefined;
40
74
  statusText?: undefined;
75
+ category?: undefined;
41
76
  errorPreview?: undefined;
42
77
  } | {
43
78
  success: boolean;
44
79
  status: number;
45
80
  statusText: string;
81
+ category: ResponseCategory;
46
82
  errorPreview: string;
47
83
  error?: undefined;
48
84
  } | {
@@ -50,6 +86,7 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
50
86
  status: number;
51
87
  error?: undefined;
52
88
  statusText?: undefined;
89
+ category?: undefined;
53
90
  errorPreview?: undefined;
54
91
  }, import("@ai-sdk/provider-utils").Context>>;
55
92
  finish: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
@@ -88,3 +125,5 @@ export interface FishermanResult {
88
125
  reason: string;
89
126
  }>;
90
127
  }
128
+ type ResponseCategory = 'validation' | 'authorization' | 'not_found' | 'conflict' | 'temporary' | 'server' | 'client';
129
+ export {};
@@ -23,6 +23,29 @@ export function createFishermanTools(apiClient, requestStore, opts) {
23
23
  tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
24
24
  const captured = requestStore.findCapturedRequest(method, path);
25
25
  if (captured) {
26
+ if (captured.status >= 400) {
27
+ const rejectedCapture = {
28
+ status: captured.status,
29
+ requestBody: captured.requestBody || 'no body',
30
+ };
31
+ if (opts.spec) {
32
+ try {
33
+ const definition = extractEndpointDefinition(opts.spec, path, opts.baseEndpoint);
34
+ return { source: 'spec', method, path, definition, rejectedCapture };
35
+ }
36
+ catch {
37
+ return { source: 'captured', method, path, usable: false, rejectedRequestBody: captured.requestBody || 'no body', status: captured.status };
38
+ }
39
+ }
40
+ return {
41
+ source: 'captured',
42
+ method: captured.method,
43
+ path: captured.path,
44
+ status: captured.status,
45
+ usable: false,
46
+ rejectedRequestBody: captured.requestBody || 'no body',
47
+ };
48
+ }
26
49
  return {
27
50
  source: 'captured',
28
51
  method: captured.method,
@@ -74,6 +97,7 @@ export function createFishermanTools(apiClient, requestStore, opts) {
74
97
  success: false,
75
98
  status: reqResult.status,
76
99
  statusText: reqResult.statusText,
100
+ category: responseCategory(reqResult.status),
77
101
  errorPreview: reqResult.rawResponseBody.substring(0, 300),
78
102
  };
79
103
  }
@@ -127,6 +151,21 @@ export function createFishermanTools(apiClient, requestStore, opts) {
127
151
  };
128
152
  return { tools, getResult, isFinished };
129
153
  }
154
+ function responseCategory(status) {
155
+ if (status === 400 || status === 422)
156
+ return 'validation';
157
+ if (status === 401 || status === 403)
158
+ return 'authorization';
159
+ if (status === 404)
160
+ return 'not_found';
161
+ if (status === 409)
162
+ return 'conflict';
163
+ if (status === 408 || status === 425 || status === 429)
164
+ return 'temporary';
165
+ if (status >= 500)
166
+ return 'server';
167
+ return 'client';
168
+ }
130
169
  function extractKeyFields(body, result = {}, depth = 0) {
131
170
  if (!body || typeof body !== 'object' || depth > 5)
132
171
  return result;
@@ -170,7 +170,8 @@ export class Fisherman {
170
170
  RULES:
171
171
  - Always call getEndpointSpec before your first request to an unfamiliar endpoint
172
172
  - Chain requests logically — create parent resources before children
173
- - If a request fails, try once more with adjusted data before reporting failure
173
+ - Use the response category and error text to decide what failed: validation requires corrected data, authorization requires valid access, not_found requires a valid path or parent, and conflict requires resolving the conflicting state
174
+ - Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
174
175
  - Use realistic but unique data for each item (vary names, titles)
175
176
 
176
177
  ${dataProtectionRules}
@@ -39,12 +39,13 @@ declare class Navigator implements Agent {
39
39
  resolveState(message: string, actionResult: ActionResult, opts?: {
40
40
  action?: Action;
41
41
  expectedUrl?: string;
42
+ experience?: string;
42
43
  onAttempt?: (attempt: {
43
44
  code: string;
44
45
  error?: string;
45
46
  }) => void;
46
47
  }): Promise<boolean>;
47
- buildResolutionPrompt(message: string, actionResult: ActionResult): Promise<string>;
48
+ buildResolutionPrompt(message: string, actionResult: ActionResult, injectedExperience?: string): Promise<string>;
48
49
  buildRetryFeedback(failures: BatchFailure[], includeHtml: boolean, actionResult: ActionResult): Promise<string>;
49
50
  executeAttempt(action: Action, codeBlock: string, message: string): Promise<{
50
51
  ok: boolean;
@@ -200,7 +200,7 @@ class Navigator {
200
200
  const expectedUrl = opts?.expectedUrl;
201
201
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
202
202
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
203
- conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
203
+ conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
204
204
  let stopReason = null;
205
205
  const tools = {
206
206
  stop: tool({
@@ -333,14 +333,10 @@ class Navigator {
333
333
  }
334
334
  return resolved;
335
335
  }
336
- async buildResolutionPrompt(message, actionResult) {
337
- let experience = '';
338
- if (!actionResult.isInsideIframe) {
339
- const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
340
- if (successful.length > 0) {
341
- tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
342
- experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
343
- }
336
+ async buildResolutionPrompt(message, actionResult, injectedExperience) {
337
+ let experience = injectedExperience || '';
338
+ if (!experience && !actionResult.isInsideIframe) {
339
+ experience = this.experienceTracker.renderExperienceFor(actionResult);
344
340
  }
345
341
  return dedent `
346
342
  <message>
@@ -15,6 +15,8 @@ import { isInteractive } from "./task-agent.js";
15
15
  import { withdrawVisionTools } from "./tools.js";
16
16
  const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
17
17
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
18
+ const PILOT_MESSAGE_LIMIT = 2;
19
+ const PILOT_MESSAGE_MAX_LENGTH = 160;
18
20
  export class Pilot {
19
21
  emoji = '🧭';
20
22
  provider;
@@ -411,9 +413,13 @@ export class Pilot {
411
413
  the elements needed for the scenario. The page summary does not list every element.
412
414
  Prefer interacting with the current page over navigating away.
413
415
 
414
- If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
415
- raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
416
- steps 1–3, then…") and call out anywhere your scenario diverges from it.
416
+ Tester never sees <experience> a recorded recipe reaches it only when you open one.
417
+ The entries listed are what was recorded on the page you are on now; recipes for the
418
+ pages this test moves to are listed when it gets there. Open the ones whose titles fit a
419
+ step taken from here, and say so in the plan when none of them fit.
420
+ Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
421
+ automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
422
+ anywhere your scenario diverges from it.
417
423
 
418
424
  Be concise and specific. Tester will follow your plan.
419
425
  `, 'pilot.planTest', { tools: true, maxToolRoundtrips: 3, task });
@@ -452,7 +458,9 @@ export class Pilot {
452
458
  ${this.formatExpectations(task)}
453
459
 
454
460
  First: evaluate whether this navigation makes sense for the scenario goal. If the page is unrelated, instruct Tester to back() or reset(). Then plan next steps.
455
- `, 'pilot.reviewNewPage', { task });
461
+
462
+ Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
463
+ `, 'pilot.reviewNewPage', { tools: true, maxToolRoundtrips: 2, task });
456
464
  }
457
465
  async analyzeProgress(task, currentState, testerConversation) {
458
466
  tag('substep').log('Pilot analyzing progress...');
@@ -479,6 +487,8 @@ export class Pilot {
479
487
  </recent_actions>
480
488
 
481
489
  What should Tester do next?
490
+
491
+ Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
482
492
  `, 'pilot.analyze', { tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task });
483
493
  const contextToAttach = await this.fetchRequestedContext(text, currentState);
484
494
  if (contextToAttach) {
@@ -580,10 +590,10 @@ export class Pilot {
580
590
  debugLog(`sendToPilot: ${functionId}, tools: ${!!opts.tools}, roundtrips: ${opts.maxToolRoundtrips ?? 0}`);
581
591
  let finalUserText = userText;
582
592
  if (opts.tools) {
593
+ this.conversation.cleanupTag('experience', '...cleaned experience index...');
583
594
  const tocBlock = this.getExperienceToc();
584
- if (tocBlock) {
595
+ if (tocBlock)
585
596
  finalUserText = `${tocBlock}\n\n${userText}`;
586
- }
587
597
  }
588
598
  this.conversation.addUserText(finalUserText);
589
599
  const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
@@ -595,9 +605,10 @@ export class Pilot {
595
605
  telemetry: { functionId },
596
606
  });
597
607
  const text = result?.response?.text || '';
598
- const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
608
+ const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => ({ url: e.output.url, content: e.output.content }));
599
609
  if (learned.length === 0)
600
610
  return text;
611
+ opts.task.applyExperience(learned);
601
612
  return dedent `
602
613
  ${text}
603
614
 
@@ -605,7 +616,7 @@ export class Pilot {
605
616
  Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
606
617
  Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
607
618
 
608
- ${learned.join('\n\n')}
619
+ ${learned.map((recipe) => recipe.content).join('\n\n')}
609
620
  </applied_experience>
610
621
  `;
611
622
  }
@@ -638,6 +649,7 @@ export class Pilot {
638
649
  return planning;
639
650
  }
640
651
  buildPreconditionTool(task) {
652
+ const unavailable = 'Data was not created and cannot be created automatically. Do not call precondition again for this test — continue with what the page already shows.';
641
653
  return {
642
654
  precondition: tool({
643
655
  description: 'Create fresh disposable data that the test will act on (edit, delete, filter). Describe WHAT to create, not what exists. Do NOT request users. Examples: "1 post", "1 comment", "1 label named Bug".',
@@ -652,7 +664,7 @@ export class Pilot {
652
664
  const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
653
665
  if (skipReason)
654
666
  return { noted: true, prepared: false, skipped: true, reason: skipReason };
655
- return { noted: true, prepared: false, reason: 'Fisherman not available' };
667
+ return { noted: true, prepared: false, reason: unavailable };
656
668
  }
657
669
  const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
658
670
  if (!result.success || result.created.length === 0) {
@@ -661,7 +673,7 @@ export class Pilot {
661
673
  const skipReason = await this.checkDataAvailability(task, description, result.summary);
662
674
  if (skipReason)
663
675
  return { noted: true, prepared: false, skipped: true, reason: skipReason };
664
- return { noted: true, prepared: false, reason: result.summary };
676
+ return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
665
677
  }
666
678
  const items = result.created.map((c) => {
667
679
  const parts = [c.type];
@@ -696,7 +708,7 @@ export class Pilot {
696
708
 
697
709
  Reply with YES or NO on the first line, then a one-sentence reason on the second line.
698
710
  `;
699
- const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
711
+ const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
700
712
  if (!answer)
701
713
  return null;
702
714
  const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
@@ -779,14 +791,6 @@ export class Pilot {
779
791
  }
780
792
  async fetchRequestedContext(text, currentState) {
781
793
  const parts = [];
782
- if (text.includes('ATTACH_HTML')) {
783
- const html = await currentState.simplifiedHtml();
784
- parts.push(dedent `
785
- <page_html>
786
- ${html}
787
- </page_html>
788
- `);
789
- }
790
794
  if (text.includes('ATTACH_ARIA')) {
791
795
  parts.push(dedent `
792
796
  <page_aria>
@@ -942,6 +946,19 @@ export class Pilot {
942
946
  const ariaDiff = t.output?.pageDiff?.ariaChanges;
943
947
  if (ariaDiff)
944
948
  line += `\n ${ariaDiff}`;
949
+ if (t.output?.pageDiff?.urlChanged)
950
+ line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
951
+ const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r) => r.status >= 400);
952
+ if (failedRequests.length > 0) {
953
+ line += `\n requests: ${failedRequests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
954
+ }
955
+ const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
956
+ if (messages.length > 0) {
957
+ line += `\n messages: ${messages.map((m) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
958
+ }
959
+ const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
960
+ if (consoleError)
961
+ line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
945
962
  return line;
946
963
  })
947
964
  .join('\n\n');
@@ -1007,10 +1024,10 @@ export class Pilot {
1007
1024
  role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
1008
1025
  XPath into NEXT instruction.
1009
1026
 
1010
- To request more context, mention ATTACH_HTML, ATTACH_ARIA, or ATTACH_UI_MAP — only when recent actions show failures.
1027
+ To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
1011
1028
 
1012
- Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck, visualClick,
1013
- back, getVisitedStates, reset, stop, finish, record.
1029
+ Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1030
+ visualClick, back, getVisitedStates, reset, stop, finish, record.
1014
1031
  Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
1015
1032
 
1016
1033
  ${capabilityGroundingRule}
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import { normalizeUrl } from "../../state-manager.js";
4
- import { isDynamicSegment } from "../../utils/url-matcher.js";
4
+ import { isSamePageFamily } from "../../utils/url-matcher.js";
5
5
  const planRegistry = new Map();
6
6
  export function registerPlan(url, plan, feature, stateHash) {
7
7
  const key = buildKey(url, feature);
@@ -28,21 +28,7 @@ function buildKey(url, feature) {
28
28
  return normalized;
29
29
  }
30
30
  export function isTemplateMatch(urlA, urlB) {
31
- const partsA = normalizeUrl(urlA).split('/');
32
- const partsB = normalizeUrl(urlB).split('/');
33
- if (partsA.length !== partsB.length)
34
- return false;
35
- let diffCount = 0;
36
- for (let i = 0; i < partsA.length; i++) {
37
- if (partsA[i] === partsB[i])
38
- continue;
39
- diffCount++;
40
- if (diffCount > 1)
41
- return false;
42
- if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i]))
43
- return false;
44
- }
45
- return diffCount === 1;
31
+ return isSamePageFamily(urlA, urlB);
46
32
  }
47
33
  export function getPlannedByStateHash(hash) {
48
34
  for (const record of planRegistry.values()) {
@@ -127,7 +127,7 @@ export class Planner extends PlannerBase {
127
127
  }
128
128
  const actionResult = ActionResult.fromState(state);
129
129
  const combinedHtml = await actionResult.combinedHtml();
130
- const similarHash = await findSimilarStateHash(combinedHtml);
130
+ const similarHash = await findSimilarStateHash(combinedHtml, state.url);
131
131
  if (similarHash) {
132
132
  const planned = getPlannedByStateHash(similarHash);
133
133
  if (planned) {