explorbot 0.3.5 → 0.4.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 (102) hide show
  1. package/bin/explorbot-cli.ts +18 -13
  2. package/boat/doc-collector/src/cli.ts +3 -0
  3. package/boat/doc-collector/src/docbot.ts +3 -1
  4. package/boat/prima/src/cli.ts +21 -8
  5. package/boat/prima/src/envelope.ts +35 -9
  6. package/boat/prima/src/prima.ts +23 -10
  7. package/dist/bin/explorbot-cli.js +19 -13
  8. package/dist/boat/doc-collector/src/cli.js +3 -0
  9. package/dist/boat/doc-collector/src/docbot.js +3 -1
  10. package/dist/boat/prima/src/cli.js +19 -8
  11. package/dist/boat/prima/src/envelope.js +24 -6
  12. package/dist/boat/prima/src/prima.js +23 -11
  13. package/dist/package.json +2 -2
  14. package/dist/src/action-result.d.ts +9 -1
  15. package/dist/src/action-result.js +57 -18
  16. package/dist/src/action.d.ts +1 -1
  17. package/dist/src/action.js +87 -12
  18. package/dist/src/ai/driller.d.ts +0 -1
  19. package/dist/src/ai/driller.js +8 -20
  20. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  21. package/dist/src/ai/fisherman-tools.js +52 -6
  22. package/dist/src/ai/fisherman.d.ts +4 -2
  23. package/dist/src/ai/fisherman.js +48 -27
  24. package/dist/src/ai/historian/codeceptjs.js +1 -1
  25. package/dist/src/ai/historian/playwright.js +1 -1
  26. package/dist/src/ai/pilot.d.ts +1 -0
  27. package/dist/src/ai/pilot.js +13 -1
  28. package/dist/src/ai/provider.js +20 -3
  29. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  30. package/dist/src/ai/researcher.js +3 -3
  31. package/dist/src/ai/tester.d.ts +3 -0
  32. package/dist/src/ai/tester.js +40 -3
  33. package/dist/src/ai/tools.d.ts +1 -0
  34. package/dist/src/ai/tools.js +13 -6
  35. package/dist/src/api/request-result.d.ts +2 -0
  36. package/dist/src/api/request-result.js +8 -2
  37. package/dist/src/api/request-store.d.ts +3 -2
  38. package/dist/src/api/request-store.js +66 -14
  39. package/dist/src/commands/explore-command.d.ts +6 -0
  40. package/dist/src/commands/explore-command.js +27 -2
  41. package/dist/src/commands/freesail-command.js +10 -1
  42. package/dist/src/commands/plans-command.js +6 -6
  43. package/dist/src/config.js +1 -0
  44. package/dist/src/experience-tracker.js +5 -0
  45. package/dist/src/explorbot.d.ts +0 -1
  46. package/dist/src/explorbot.js +23 -36
  47. package/dist/src/state-manager.d.ts +5 -1
  48. package/dist/src/state-manager.js +10 -7
  49. package/dist/src/test-plan.d.ts +3 -0
  50. package/dist/src/test-plan.js +27 -0
  51. package/dist/src/utils/aria.d.ts +1 -1
  52. package/dist/src/utils/aria.js +6 -42
  53. package/dist/src/utils/html-diff.d.ts +4 -0
  54. package/dist/src/utils/html-diff.js +62 -7
  55. package/dist/src/utils/html.d.ts +5 -15
  56. package/dist/src/utils/html.js +14 -85
  57. package/dist/src/utils/overlay.d.ts +56 -11
  58. package/dist/src/utils/overlay.js +191 -21
  59. package/dist/src/utils/request-map.d.ts +7 -0
  60. package/dist/src/utils/request-map.js +16 -0
  61. package/dist/src/utils/url-matcher.js +4 -2
  62. package/docs/reference/commands.md +8 -1
  63. package/docs/reference/websocket.md +1 -0
  64. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  65. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  66. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  67. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  68. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  69. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  70. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  71. package/docs/workflow/agentic-usage.md +1 -0
  72. package/docs/workflow/ci.md +1 -0
  73. package/package.json +2 -2
  74. package/src/action-result.ts +61 -22
  75. package/src/action.ts +87 -14
  76. package/src/ai/driller.ts +7 -39
  77. package/src/ai/fisherman-tools.ts +56 -7
  78. package/src/ai/fisherman.ts +48 -28
  79. package/src/ai/historian/codeceptjs.ts +1 -1
  80. package/src/ai/historian/playwright.ts +1 -1
  81. package/src/ai/pilot.ts +9 -1
  82. package/src/ai/provider.ts +21 -3
  83. package/src/ai/researcher/deep-analysis.ts +1 -2
  84. package/src/ai/researcher.ts +3 -3
  85. package/src/ai/tester.ts +40 -3
  86. package/src/ai/tools.ts +17 -9
  87. package/src/api/request-result.ts +10 -2
  88. package/src/api/request-store.ts +60 -13
  89. package/src/commands/explore-command.ts +25 -2
  90. package/src/commands/freesail-command.ts +7 -1
  91. package/src/commands/plans-command.ts +6 -6
  92. package/src/config.ts +1 -0
  93. package/src/experience-tracker.ts +5 -1
  94. package/src/explorbot.ts +20 -36
  95. package/src/state-manager.ts +13 -7
  96. package/src/test-plan.ts +29 -0
  97. package/src/utils/aria.ts +7 -44
  98. package/src/utils/html-diff.ts +62 -7
  99. package/src/utils/html.ts +14 -91
  100. package/src/utils/overlay.ts +226 -23
  101. package/src/utils/request-map.ts +19 -0
  102. package/src/utils/url-matcher.ts +3 -2
@@ -3,11 +3,23 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { extractEndpointDefinition } from "../api/spec-reader.js";
5
5
  import { tag } from "../utils/logger.js";
6
+ import { RequestMap } from "../utils/request-map.js";
7
+ import { isDynamicSegment } from "../utils/url-matcher.js";
6
8
  export function createFishermanTools(apiClient, requestStore, opts) {
7
9
  let finished = false;
8
- let result = { success: false, summary: '', created: [], failed: [] };
9
- const getResult = () => result;
10
+ let result = null;
11
+ const ledgerStart = requestStore.getMadeRequests().length;
12
+ const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
13
+ const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
14
+ const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
10
15
  const isFinished = () => finished;
16
+ const finishFromText = (text) => {
17
+ finished = true;
18
+ const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
19
+ if (text && synthesized.success)
20
+ synthesized.summary = text;
21
+ result = synthesized;
22
+ };
11
23
  const tools = {
12
24
  getEndpointSpec: tool({
13
25
  description: dedent `
@@ -69,7 +81,7 @@ export function createFishermanTools(apiClient, requestStore, opts) {
69
81
  request: tool({
70
82
  description: dedent `
71
83
  Make an HTTP request to the API.
72
- Returns status, timing, and auto-extracted IDs and names from the response.
84
+ Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
73
85
  `,
74
86
  inputSchema: z.object({
75
87
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
@@ -106,7 +118,7 @@ export function createFishermanTools(apiClient, requestStore, opts) {
106
118
  return {
107
119
  success: true,
108
120
  status: reqResult.status,
109
- ...extracted,
121
+ extracted,
110
122
  };
111
123
  },
112
124
  }),
@@ -130,9 +142,30 @@ export function createFishermanTools(apiClient, requestStore, opts) {
130
142
  .describe('List of items that could not be created'),
131
143
  }),
132
144
  execute: async ({ summary, created, failed }) => {
145
+ const writes = successfulWrites();
146
+ if (writes.length === 0) {
147
+ tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
148
+ return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
149
+ }
150
+ const createdRequests = new RequestMap(writes);
151
+ const verified = [];
152
+ for (const item of created) {
153
+ if (item.id === undefined) {
154
+ verified.push(item);
155
+ continue;
156
+ }
157
+ const request = createdRequests.get(item.id);
158
+ if (!request) {
159
+ tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
160
+ continue;
161
+ }
162
+ verified.push({ ...item, request: request.toEndpoint() });
163
+ }
164
+ if (verified.length === 0)
165
+ verified.push(...writes.map(toCreatedItem));
133
166
  tag('success').log(`Fisherman done: ${summary}`);
134
167
  finished = true;
135
- result = { success: true, summary, created, failed: failed || [] };
168
+ result = { success: true, summary, created: verified, failed: failed || [] };
136
169
  return { finished: true };
137
170
  },
138
171
  }),
@@ -149,7 +182,20 @@ export function createFishermanTools(apiClient, requestStore, opts) {
149
182
  },
150
183
  }),
151
184
  };
152
- return { tools, getResult, isFinished };
185
+ return { tools, getResult, isFinished, finishFromText };
186
+ }
187
+ function synthesizeResult(made, writes, declaredDone) {
188
+ const failures = made.filter((r) => r.status >= 400 || r.error);
189
+ let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
190
+ const lastFailure = failures[failures.length - 1];
191
+ if (lastFailure)
192
+ summary += `; last failure: ${lastFailure.toSummary()}`;
193
+ return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
194
+ }
195
+ function toCreatedItem(write) {
196
+ const { id, title } = write.extractIdAndTitle();
197
+ const segments = write.path.split('/').filter((s) => s && !isDynamicSegment(s));
198
+ return { type: segments[segments.length - 1] || 'item', id, title, request: write.toEndpoint() };
153
199
  }
154
200
  function responseCategory(status) {
155
201
  if (status === 400 || status === 422)
@@ -9,14 +9,15 @@ export declare class Fisherman implements Agent {
9
9
  apiClient: ApiClient;
10
10
  requestStore: RequestStore;
11
11
  specLoader: () => Promise<any | null>;
12
- cookieProvider: () => Promise<Record<string, string>>;
12
+ browserHeaderProvider: () => Promise<Record<string, string>>;
13
13
  configHeaders: Record<string, string>;
14
14
  sessionName?: string;
15
15
  baseEndpoint: string;
16
16
  spec: any | null;
17
17
  mode: 'replicate' | 'achieve' | 'disabled';
18
18
  hasApiConfig: boolean;
19
- constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string, cookieProvider: () => Promise<Record<string, string>>, configHeaders?: Record<string, string>, hasApiConfig?: boolean);
19
+ scopeDegraded: boolean;
20
+ constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string, browserHeaderProvider: () => Promise<Record<string, string>>, configHeaders?: Record<string, string>, hasApiConfig?: boolean);
20
21
  isAvailable(): boolean;
21
22
  ensureReady(scopeUrl?: string): Promise<void>;
22
23
  getEndpointList(scopeUrl?: string): string;
@@ -25,5 +26,6 @@ export declare class Fisherman implements Agent {
25
26
  refreshAuth(): Promise<void>;
26
27
  buildEndpointList(scopeUrl?: string): string;
27
28
  buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
29
+ isStuckOnEndpoint(ledgerStart: number): boolean;
28
30
  buildTaskPrompt(instructions: string): string;
29
31
  }
@@ -7,26 +7,28 @@ import { createFishermanTools } from "./fisherman-tools.js";
7
7
  import { dataProtectionRules } from "./rules.js";
8
8
  const MAX_ITERATIONS = 15;
9
9
  const MAX_TOOL_ROUNDTRIPS = 5;
10
+ const REPEATED_FAILURE_LIMIT = 4;
10
11
  export class Fisherman {
11
12
  emoji = '🎣';
12
13
  provider;
13
14
  apiClient;
14
15
  requestStore;
15
16
  specLoader;
16
- cookieProvider;
17
+ browserHeaderProvider;
17
18
  configHeaders;
18
19
  sessionName;
19
20
  baseEndpoint;
20
21
  spec = null;
21
22
  mode = 'disabled';
22
23
  hasApiConfig;
23
- constructor(provider, apiClient, requestStore, specLoader, baseEndpoint, cookieProvider, configHeaders = {}, hasApiConfig = false) {
24
+ scopeDegraded = false;
25
+ constructor(provider, apiClient, requestStore, specLoader, baseEndpoint, browserHeaderProvider, configHeaders = {}, hasApiConfig = false) {
24
26
  this.provider = provider;
25
27
  this.apiClient = apiClient;
26
28
  this.requestStore = requestStore;
27
29
  this.specLoader = specLoader;
28
30
  this.baseEndpoint = baseEndpoint;
29
- this.cookieProvider = cookieProvider;
31
+ this.browserHeaderProvider = browserHeaderProvider;
30
32
  this.configHeaders = configHeaders;
31
33
  this.hasApiConfig = hasApiConfig;
32
34
  this.mode = hasApiConfig ? 'achieve' : 'replicate';
@@ -59,17 +61,17 @@ export class Fisherman {
59
61
  }
60
62
  await this.refreshAuth();
61
63
  debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
62
- const { tools, getResult, isFinished } = createFishermanTools(this.apiClient, this.requestStore, {
64
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
63
65
  spec: this.spec,
64
66
  baseEndpoint: this.baseEndpoint,
65
67
  });
68
+ const ledgerStart = this.requestStore.getMadeRequests().length;
66
69
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
67
70
  conversation.addUserText(this.buildTaskPrompt(instructions));
68
71
  await loop(async ({ stop, iteration }) => {
69
72
  debugLog(`iteration ${iteration}`);
70
73
  const invokeResult = await this.provider.invokeConversation(conversation, tools, {
71
74
  maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
72
- toolChoice: 'required',
73
75
  agentName: 'fisherman',
74
76
  });
75
77
  debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
@@ -77,6 +79,17 @@ export class Fisherman {
77
79
  stop();
78
80
  return;
79
81
  }
82
+ if (!invokeResult?.toolExecutions?.length) {
83
+ debugLog('no tool call in this turn — treating as finish');
84
+ finishFromText(invokeResult?.response?.text);
85
+ stop();
86
+ return;
87
+ }
88
+ if (this.isStuckOnEndpoint(ledgerStart)) {
89
+ tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
90
+ stop();
91
+ return;
92
+ }
80
93
  if (iteration >= MAX_ITERATIONS) {
81
94
  tag('warning').log('Fisherman: max iterations reached');
82
95
  stop();
@@ -114,41 +127,40 @@ export class Fisherman {
114
127
  this.mode = 'disabled';
115
128
  }
116
129
  async refreshAuth() {
117
- const cookies = await this.cookieProvider();
118
- if (Object.keys(cookies).length > 0) {
119
- this.apiClient.setHeaders(cookies);
120
- }
121
- const xhrHeaders = this.requestStore.extractAuthHeaders();
122
- if (Object.keys(xhrHeaders).length > 0) {
123
- this.apiClient.setHeaders(xhrHeaders);
130
+ if (this.mode === 'replicate') {
131
+ const xhrHeaders = this.requestStore.extractAuthHeaders();
132
+ if (Object.keys(xhrHeaders).length > 0) {
133
+ this.apiClient.setHeaders(xhrHeaders);
134
+ }
135
+ const browserHeaders = await this.browserHeaderProvider();
136
+ if (Object.keys(browserHeaders).length > 0) {
137
+ this.apiClient.setHeaders(browserHeaders);
138
+ }
124
139
  }
125
140
  if (Object.keys(this.configHeaders).length > 0) {
126
141
  this.apiClient.setHeaders(this.configHeaders);
127
142
  }
128
143
  }
129
144
  buildEndpointList(scopeUrl) {
145
+ this.scopeDegraded = false;
130
146
  if (this.mode === 'achieve' && this.spec) {
131
147
  const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
132
148
  if (specEndpoints)
133
149
  return specEndpoints;
134
150
  }
135
- let writeRequests = this.requestStore.getWriteRequestsForScope(scopeUrl || '/');
136
- if (writeRequests.length === 0) {
137
- writeRequests = this.requestStore.getWriteRequestsForScope('/');
138
- }
139
- const seen = new Set();
140
- const lines = [];
141
- for (const req of writeRequests) {
142
- const key = `${req.method} ${req.path}`;
143
- if (seen.has(key))
144
- continue;
145
- seen.add(key);
146
- lines.push(key);
147
- }
148
- return lines.join('\n');
151
+ const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
152
+ if (scoped)
153
+ return scoped;
154
+ this.scopeDegraded = true;
155
+ return this.requestStore.toEndpointList();
149
156
  }
150
157
  buildSystemPrompt(endpointList, toolNames, scopeUrl) {
151
- const scopeBlock = scopeUrl ? `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.` : '';
158
+ let scopeBlock = '';
159
+ if (scopeUrl) {
160
+ scopeBlock = `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.`;
161
+ if (this.scopeDegraded)
162
+ scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Before writing, confirm the target belongs to this scope.';
163
+ }
152
164
  return dedent `
153
165
  You are Fisherman — a data preparation agent. You create test data by making API requests.
154
166
 
@@ -172,11 +184,20 @@ export class Fisherman {
172
184
  - Chain requests logically — create parent resources before children
173
185
  - 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
186
  - Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
187
+ - Create only the resource types that were requested. If no endpoint creates a requested type, call stop — never create a different type as a substitute
175
188
  - Use realistic but unique data for each item (vary names, titles)
176
189
 
177
190
  ${dataProtectionRules}
178
191
  `;
179
192
  }
193
+ isStuckOnEndpoint(ledgerStart) {
194
+ const made = this.requestStore.getMadeRequests().slice(ledgerStart);
195
+ if (made.length < REPEATED_FAILURE_LIMIT)
196
+ return false;
197
+ const recent = made.slice(-REPEATED_FAILURE_LIMIT);
198
+ const first = recent[0];
199
+ return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
200
+ }
180
201
  buildTaskPrompt(instructions) {
181
202
  return dedent `
182
203
  Prepare the following test data:
@@ -45,7 +45,7 @@ export function WithCodeceptJS(Base) {
45
45
  lines.push('');
46
46
  lines.push(`Feature('${escapeString(plan.title)}')`);
47
47
  lines.push('');
48
- const startUrl = plan.url || plan.tests[0]?.startUrl;
48
+ const startUrl = plan.startUrl;
49
49
  if (startUrl) {
50
50
  lines.push('Before(({ I }) => {');
51
51
  lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
@@ -78,7 +78,7 @@ export function WithPlaywright(Base) {
78
78
  lines.push(`import { test, expect } from '@playwright/test';`);
79
79
  lines.push('');
80
80
  lines.push(`test.describe('${escapeString(plan.title)}', () => {`);
81
- const startUrl = plan.url || plan.tests[0]?.startUrl;
81
+ const startUrl = plan.startUrl;
82
82
  if (startUrl) {
83
83
  lines.push(' test.beforeEach(async ({ page }) => {');
84
84
  lines.push(` await page.goto('${escapeString(startUrl)}');`);
@@ -71,6 +71,7 @@ export declare class Pilot implements Agent {
71
71
  type: string;
72
72
  id?: string | number;
73
73
  title?: string;
74
+ request?: string;
74
75
  }[];
75
76
  skipped?: undefined;
76
77
  reason?: undefined;
@@ -687,6 +687,8 @@ export class Pilot {
687
687
  parts.push(`"${c.title}"`);
688
688
  if (c.id)
689
689
  parts.push(`(id: ${c.id})`);
690
+ if (c.request)
691
+ parts.push(`via ${c.request}`);
690
692
  return parts.join(' ');
691
693
  });
692
694
  const stepText = `Precondition: created ${items.join(', ')}`;
@@ -741,7 +743,16 @@ export class Pilot {
741
743
  lines.push(`h4: ${state.h4 || ''}`);
742
744
  const focusArea = state.overlay;
743
745
  if (focusArea.detected) {
744
- lines.push(`modal: ${focusArea.name || focusArea.type}`);
746
+ let line = `modal: ${focusArea.name || focusArea.type}`;
747
+ if (focusArea.root)
748
+ line += ` (root: ${focusArea.root})`;
749
+ lines.push(line);
750
+ }
751
+ else if (focusArea.present) {
752
+ let line = `region: ${focusArea.name || 'unnamed'} (inline`;
753
+ if (focusArea.root)
754
+ line += `, root: ${focusArea.root}`;
755
+ lines.push(`${line})`);
745
756
  }
746
757
  else {
747
758
  lines.push('modal: none');
@@ -1024,6 +1035,7 @@ export class Pilot {
1024
1035
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1025
1036
  - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1026
1037
  - "modal: none" but Tester targets a modal → modal closed; re-trigger.
1038
+ - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1027
1039
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
1028
1040
  - MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
1029
1041
  - Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
@@ -385,9 +385,19 @@ export class Provider {
385
385
  const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
386
386
  let attemptMessages = messages;
387
387
  let invalidRequestFeedbackAdded = false;
388
+ const executedStepMessages = [];
388
389
  try {
389
390
  const response = await this.withModelRequestSlot(() => withRetry(async () => {
390
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal }), config.timeout || 30000).catch((error) => {
391
+ const stepMessages = [];
392
+ const onStepEnd = (step) => {
393
+ stepMessages.push(...(step.response?.messages || []));
394
+ };
395
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal, onStepEnd }), config.timeout || 30000).catch((error) => {
396
+ if (stepMessages.length > 0) {
397
+ tag('warning').log(`Keeping ${stepMessages.length} messages from tool steps that already ran before the failure`);
398
+ executedStepMessages.push(...stepMessages);
399
+ attemptMessages = [...attemptMessages, ...stepMessages];
400
+ }
391
401
  if (!invalidRequestFeedbackAdded) {
392
402
  const amended = withInvalidRequestFeedback(attemptMessages, error);
393
403
  invalidRequestFeedbackAdded = amended !== attemptMessages;
@@ -403,6 +413,7 @@ export class Provider {
403
413
  return result;
404
414
  }, this.getRetryOptions(options)));
405
415
  clearActivity();
416
+ withExecutedSteps(response, executedStepMessages);
406
417
  // Log tool usage summary
407
418
  if (response.toolCalls && response.toolCalls.length > 0) {
408
419
  responseLog(response.toolCalls);
@@ -416,14 +427,15 @@ export class Provider {
416
427
  catch (error) {
417
428
  clearActivity();
418
429
  if (error?.message?.includes('Tool choice is required')) {
419
- return { text: '', toolCalls: [], toolResults: [], response: { messages: [] }, usage: null };
430
+ return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
420
431
  }
421
432
  if (error?.name === 'AbortError')
422
433
  throw error;
423
434
  if (error instanceof ContextLengthError)
424
435
  throw error;
425
436
  if (Provider.isContextLengthError(error)) {
426
- return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateWithTools(m, model, tools, o));
437
+ const recovered = await this.recoverFromContextLength(error, attemptMessages, options, (m, o) => this.generateWithTools(m, model, tools, o));
438
+ return withExecutedSteps(recovered, executedStepMessages);
427
439
  }
428
440
  if (error.constructor?.name === 'AI_APICallError') {
429
441
  responseLog(error.message);
@@ -630,6 +642,11 @@ function repairToolCall(options) {
630
642
  return repairChannelMarker(options);
631
643
  return repairHarmonyChannel(options);
632
644
  }
645
+ function withExecutedSteps(result, executed) {
646
+ if (executed.length === 0)
647
+ return result;
648
+ return Object.defineProperty(result, 'responseMessages', { value: [...executed, ...(result.responseMessages || [])], configurable: true, enumerable: true });
649
+ }
633
650
  function withInvalidRequestFeedback(messages, error) {
634
651
  if (!(error instanceof APICallError) || error.statusCode !== 400)
635
652
  return messages;
@@ -63,9 +63,7 @@ export function WithDeepAnalysis(Base) {
63
63
  }
64
64
  async researchOverlay(current, previous, pageStateHash) {
65
65
  const focusArea = current.overlay;
66
- if (!focusArea.detected || !focusArea.name)
67
- return null;
68
- if (focusArea.type !== 'dialog' && focusArea.type !== 'modal')
66
+ if (!focusArea.present || !focusArea.name)
69
67
  return null;
70
68
  const cached = getCachedResearch(pageStateHash);
71
69
  if (!cached)
@@ -53,7 +53,7 @@ export class Researcher extends ResearcherBase {
53
53
  throw new Error('not implemented');
54
54
  }
55
55
  static getCachedResearch(state) {
56
- return getCachedResearch(state.hash || '');
56
+ return getCachedResearch(ActionResult.fromState(state).baseHash);
57
57
  }
58
58
  getSystemMessage() {
59
59
  const currentUrl = this.stateManager.getCurrentState()?.url;
@@ -71,7 +71,7 @@ export class Researcher extends ResearcherBase {
71
71
  const maxRetries = this.config.ai?.agents?.researcher?.retries ?? 2;
72
72
  let retriesLeft = opts._retriesLeft ?? maxRetries;
73
73
  this.actionResult = ActionResult.fromState(state);
74
- const stateHash = state.hash || this.actionResult.getStateHash();
74
+ const stateHash = this.actionResult.baseHash;
75
75
  const researchState = { ...state, hash: stateHash };
76
76
  if (!force && stateHash) {
77
77
  const cached = getCachedResearch(stateHash);
@@ -223,7 +223,7 @@ export class Researcher extends ResearcherBase {
223
223
  }
224
224
  if (!interrupted() && deep) {
225
225
  try {
226
- await this.performDeepAnalysis(state, result);
226
+ await this.performDeepAnalysis(researchState, result);
227
227
  }
228
228
  catch (err) {
229
229
  tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
@@ -32,6 +32,8 @@ export declare class Tester extends TaskAgent implements Agent {
32
32
  seenUiMapUrls: Set<string>;
33
33
  lastAnalyzedStateHash: string | null;
34
34
  stalledIterations: number;
35
+ previousRegionPresent: boolean | null;
36
+ regionTransitioned: boolean;
35
37
  readonly MAX_STALLED_ITERATIONS = 3;
36
38
  skipResearch: (err: Error) => string;
37
39
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
@@ -96,5 +98,6 @@ interface TestSessionHandlers {
96
98
  }
97
99
  export interface TestOptions {
98
100
  startOnCurrentPage?: boolean;
101
+ deadline?: number;
99
102
  }
100
103
  export {};
@@ -50,6 +50,8 @@ export class Tester extends TaskAgent {
50
50
  seenUiMapUrls = new Set();
51
51
  lastAnalyzedStateHash = null;
52
52
  stalledIterations = 0;
53
+ previousRegionPresent = null;
54
+ regionTransitioned = false;
53
55
  MAX_STALLED_ITERATIONS = 3;
54
56
  skipResearch = (err) => {
55
57
  if (err.name === 'AbortError')
@@ -95,6 +97,8 @@ export class Tester extends TaskAgent {
95
97
  this.seenUiMapUrls.clear();
96
98
  this.lastAnalyzedStateHash = null;
97
99
  this.stalledIterations = 0;
100
+ this.previousRegionPresent = null;
101
+ this.regionTransitioned = false;
98
102
  this.stateManager.clearHistory();
99
103
  this.resetFailureCount();
100
104
  this.pilot?.reset();
@@ -214,10 +218,17 @@ export class Tester extends TaskAgent {
214
218
  const codeceptjsTools = createCodeceptJSTools(this.toolDeps, task);
215
219
  let assertionPerformed = false;
216
220
  let extensions = 0;
221
+ let deadlineReached = false;
217
222
  let shouldContinue = true;
218
223
  while (shouldContinue) {
219
224
  shouldContinue = false;
220
225
  await loop(async ({ stop, pause, iteration, userInput }) => {
226
+ if (opts.deadline != null && Date.now() >= opts.deadline) {
227
+ deadlineReached = true;
228
+ task.addNote('Time budget reached. Stopped');
229
+ stop();
230
+ return;
231
+ }
221
232
  debugLog('iteration', iteration);
222
233
  if (!(await this.explorer.recover()).ok) {
223
234
  task.addNote('Browser page is unavailable');
@@ -369,6 +380,8 @@ export class Tester extends TaskAgent {
369
380
  });
370
381
  if (task.hasFinished)
371
382
  break;
383
+ if (deadlineReached)
384
+ break;
372
385
  if (!(await this.explorer.recover()).ok)
373
386
  break;
374
387
  const finalState = this.getCurrentState();
@@ -403,6 +416,10 @@ export class Tester extends TaskAgent {
403
416
  };
404
417
  }
405
418
  shouldAnalyzeProgress(iteration, currentState) {
419
+ if (this.regionTransitioned) {
420
+ this.regionTransitioned = false;
421
+ return true;
422
+ }
406
423
  if (this.consecutiveFailures >= 3)
407
424
  return true;
408
425
  if (this.consecutiveEmptyResults >= 2)
@@ -468,6 +485,11 @@ export class Tester extends TaskAgent {
468
485
  const currentUrl = currentState.url;
469
486
  const currentStateHash = currentState.hash;
470
487
  const isNewUrl = this.previousUrl !== currentUrl;
488
+ const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
489
+ if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
490
+ this.regionTransitioned = true;
491
+ }
492
+ this.previousRegionPresent = currentState.overlay.present;
471
493
  this.previousUrl = currentUrl;
472
494
  this.previousStateHash = currentStateHash;
473
495
  let context = '';
@@ -491,13 +513,28 @@ export class Tester extends TaskAgent {
491
513
  }
492
514
  if (focusArea.detected) {
493
515
  const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
516
+ let rootHint = '';
517
+ if (focusArea.root)
518
+ rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
494
519
  context += dedent `
495
520
  <focus_scope>
496
- A ${focusArea.type}${areaName} is currently open above the page.
521
+ A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
497
522
  Scope all interactions to elements inside this ${focusArea.type}.
498
523
  Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the ${focusArea.type}.
499
524
  Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
500
525
  </focus_scope>
526
+ `;
527
+ }
528
+ if (!focusArea.detected && focusArea.present && isNewState) {
529
+ let rootHint = '';
530
+ if (focusArea.root)
531
+ rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
532
+ context += dedent `
533
+ <area_of_interest>
534
+ A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
535
+ The scenario most likely continues inside this area — prefer its elements for your next actions.
536
+ The rest of the page (navigation, menus, filters) is still interactive and remains available.
537
+ </area_of_interest>
501
538
  `;
502
539
  }
503
540
  if (currentState.isInsideIframe) {
@@ -520,7 +557,7 @@ export class Tester extends TaskAgent {
520
557
  if (!alreadySeenUiMap) {
521
558
  research = await this.researcher.research(currentState).catch(this.skipResearch);
522
559
  }
523
- this.pageStateHash = currentStateHash;
560
+ this.pageStateHash = currentState.baseHash;
524
561
  this.pageActionResult = currentState;
525
562
  let uiMapSection = '';
526
563
  if (research) {
@@ -557,7 +594,7 @@ export class Tester extends TaskAgent {
557
594
  `;
558
595
  return context;
559
596
  }
560
- if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
597
+ if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
561
598
  const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
562
599
  if (overlaySection) {
563
600
  context += dedent `
@@ -69,4 +69,5 @@ export declare function withdrawVisionTools(tools: Record<string, any>): void;
69
69
  export declare function clickFailureSuggestion(attempts: Array<{
70
70
  error?: string;
71
71
  }>): string;
72
+ export declare function formatMatchedElements(error: Error | null | undefined): Promise<string | null>;
72
73
  export {};
@@ -7,8 +7,9 @@ import { Stats } from "../stats.js";
7
7
  import { TestResult } from '../test-plan.js';
8
8
  import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
9
9
  import { isFatalBrowserError } from "../utils/browser-errors.js";
10
+ import { cleanHtmlSnippet } from "../utils/html.js";
10
11
  import { createDebug, tag } from '../utils/logger.js';
11
- import { compactErrorMessage } from "../utils/strings.js";
12
+ import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
12
13
  import { pause } from '../utils/loop.js';
13
14
  import { WebElement } from "../utils/web-element.js";
14
15
  import { sectionContextRule } from "./rules.js";
@@ -1161,6 +1162,8 @@ export function clickFailureSuggestion(attempts) {
1161
1162
  return "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
1162
1163
  }
1163
1164
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1165
+ const MAX_DISAMBIGUATE_TEXT = 80;
1166
+ const MAX_DISAMBIGUATE_HTML = 300;
1164
1167
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1165
1168
  async function extractWebElements(error) {
1166
1169
  if (!error || error.name !== 'MultipleElementsFound')
@@ -1172,8 +1175,9 @@ async function extractWebElements(error) {
1172
1175
  for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
1173
1176
  try {
1174
1177
  const xpath = await elements[i].toAbsoluteXPath();
1175
- const html = await elements[i].toSimplifiedHTML();
1176
- result.push({ xpath, html });
1178
+ const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1179
+ const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1180
+ result.push({ xpath, html, text });
1177
1181
  }
1178
1182
  catch (e) {
1179
1183
  debugLog('Failed to get details for element %d: %s', i, e);
@@ -1181,17 +1185,20 @@ async function extractWebElements(error) {
1181
1185
  }
1182
1186
  return result.length > 0 ? result : null;
1183
1187
  }
1184
- async function formatMatchedElements(error) {
1188
+ function formatElementList(details) {
1189
+ return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1190
+ }
1191
+ export async function formatMatchedElements(error) {
1185
1192
  const details = await extractWebElements(error);
1186
1193
  if (!details)
1187
1194
  return 'Could not fetch element details. Repeat the action to get better info.';
1188
- return details.map((el, i) => `Element ${i + 1}\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1195
+ return formatElementList(details);
1189
1196
  }
1190
1197
  async function disambiguateElements(error, explanation, provider) {
1191
1198
  const elementDetails = await extractWebElements(error);
1192
1199
  if (!elementDetails)
1193
1200
  return null;
1194
- const elementList = elementDetails.map((el, i) => `Element ${i + 1}:\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1201
+ const elementList = formatElementList(elementDetails);
1195
1202
  const schema = z.object({
1196
1203
  position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
1197
1204
  });
@@ -33,8 +33,10 @@ export declare class RequestResult {
33
33
  get rawResponseBody(): string;
34
34
  set rawResponseBodyValue(value: string);
35
35
  get responseBody(): any;
36
+ get isWrite(): boolean;
36
37
  save(outputDir: string): void;
37
38
  static load(requestFile: string): RequestResult;
39
+ toEndpoint(): string;
38
40
  toSummary(): string;
39
41
  extractIdAndTitle(): {
40
42
  id?: string | number;