explorbot 0.3.5 → 0.4.1

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 (155) hide show
  1. package/bin/explorbot-cli.ts +23 -16
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler.ts +7 -1
  4. package/boat/api-tester/src/apibot.ts +10 -4
  5. package/boat/api-tester/src/cli.ts +12 -2
  6. package/boat/api-tester/src/config.ts +28 -8
  7. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  8. package/boat/doc-collector/src/cli.ts +6 -1
  9. package/boat/doc-collector/src/docbot.ts +4 -2
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +23 -8
  12. package/boat/prima/src/envelope.ts +35 -9
  13. package/boat/prima/src/prima.ts +23 -10
  14. package/dist/bin/explorbot-cli.js +24 -16
  15. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  16. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  17. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  18. package/dist/boat/api-tester/src/apibot.js +7 -3
  19. package/dist/boat/api-tester/src/cli.js +12 -2
  20. package/dist/boat/api-tester/src/config.js +31 -8
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  22. package/dist/boat/doc-collector/src/cli.js +6 -1
  23. package/dist/boat/doc-collector/src/docbot.js +4 -2
  24. package/dist/boat/prima/bin/prima-cli.js +2 -0
  25. package/dist/boat/prima/src/cli.js +22 -8
  26. package/dist/boat/prima/src/envelope.js +24 -6
  27. package/dist/boat/prima/src/prima.js +23 -11
  28. package/dist/package.json +2 -2
  29. package/dist/rules/planner/styles/normal.md +1 -1
  30. package/dist/src/action-result.d.ts +9 -1
  31. package/dist/src/action-result.js +57 -18
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +87 -12
  34. package/dist/src/ai/captain.js +1 -1
  35. package/dist/src/ai/driller.d.ts +0 -1
  36. package/dist/src/ai/driller.js +8 -20
  37. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  38. package/dist/src/ai/fisherman-tools.js +52 -6
  39. package/dist/src/ai/fisherman.d.ts +4 -2
  40. package/dist/src/ai/fisherman.js +48 -27
  41. package/dist/src/ai/historian/codeceptjs.js +1 -1
  42. package/dist/src/ai/historian/playwright.js +1 -1
  43. package/dist/src/ai/navigator.js +1 -1
  44. package/dist/src/ai/pilot.d.ts +1 -0
  45. package/dist/src/ai/pilot.js +13 -1
  46. package/dist/src/ai/planner.js +9 -7
  47. package/dist/src/ai/provider.js +20 -3
  48. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  49. package/dist/src/ai/researcher.js +5 -3
  50. package/dist/src/ai/rules.js +3 -3
  51. package/dist/src/ai/tester.d.ts +3 -0
  52. package/dist/src/ai/tester.js +40 -3
  53. package/dist/src/ai/tools.d.ts +1 -0
  54. package/dist/src/ai/tools.js +13 -6
  55. package/dist/src/api/request-result.d.ts +2 -0
  56. package/dist/src/api/request-result.js +8 -2
  57. package/dist/src/api/request-store.d.ts +3 -2
  58. package/dist/src/api/request-store.js +66 -14
  59. package/dist/src/api/spec-reader.js +1 -1
  60. package/dist/src/commands/config-command.js +1 -1
  61. package/dist/src/commands/drill-command.js +1 -1
  62. package/dist/src/commands/explore-command.d.ts +6 -0
  63. package/dist/src/commands/explore-command.js +39 -3
  64. package/dist/src/commands/freesail-command.js +10 -1
  65. package/dist/src/commands/options/base-option.d.ts +8 -0
  66. package/dist/src/commands/options/base-option.js +12 -0
  67. package/dist/src/commands/options/index.d.ts +5 -0
  68. package/dist/src/commands/options/index.js +5 -0
  69. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  70. package/dist/src/commands/options/knowledge-option.js +12 -0
  71. package/dist/src/commands/options/ws-option.d.ts +7 -0
  72. package/dist/src/commands/options/ws-option.js +21 -0
  73. package/dist/src/commands/plans-command.js +6 -6
  74. package/dist/src/config.d.ts +1 -0
  75. package/dist/src/config.js +12 -0
  76. package/dist/src/experience-tracker.js +5 -0
  77. package/dist/src/explorbot.d.ts +0 -1
  78. package/dist/src/explorbot.js +24 -37
  79. package/dist/src/knowledge-tracker.d.ts +20 -7
  80. package/dist/src/knowledge-tracker.js +69 -31
  81. package/dist/src/remote.d.ts +0 -3
  82. package/dist/src/remote.js +0 -18
  83. package/dist/src/state-manager.d.ts +5 -1
  84. package/dist/src/state-manager.js +10 -7
  85. package/dist/src/test-plan.d.ts +3 -0
  86. package/dist/src/test-plan.js +27 -0
  87. package/dist/src/utils/aria.d.ts +1 -1
  88. package/dist/src/utils/aria.js +6 -42
  89. package/dist/src/utils/html-diff.d.ts +4 -0
  90. package/dist/src/utils/html-diff.js +62 -7
  91. package/dist/src/utils/html.d.ts +5 -15
  92. package/dist/src/utils/html.js +14 -85
  93. package/dist/src/utils/overlay.d.ts +56 -11
  94. package/dist/src/utils/overlay.js +191 -21
  95. package/dist/src/utils/request-map.d.ts +7 -0
  96. package/dist/src/utils/request-map.js +16 -0
  97. package/dist/src/utils/url-matcher.js +4 -2
  98. package/docs/api-testing/basics.md +15 -0
  99. package/docs/api-testing/planning.md +10 -1
  100. package/docs/reference/commands.md +32 -5
  101. package/docs/reference/websocket.md +1 -0
  102. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  103. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  104. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  105. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  106. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  107. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  108. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  109. package/docs/workflow/agentic-usage.md +12 -2
  110. package/docs/workflow/ci.md +1 -0
  111. package/docs/workflow/knowledge.md +46 -2
  112. package/package.json +2 -2
  113. package/rules/planner/styles/normal.md +1 -1
  114. package/src/action-result.ts +61 -22
  115. package/src/action.ts +87 -14
  116. package/src/ai/captain.ts +1 -1
  117. package/src/ai/driller.ts +7 -39
  118. package/src/ai/fisherman-tools.ts +56 -7
  119. package/src/ai/fisherman.ts +48 -28
  120. package/src/ai/historian/codeceptjs.ts +1 -1
  121. package/src/ai/historian/playwright.ts +1 -1
  122. package/src/ai/navigator.ts +1 -1
  123. package/src/ai/pilot.ts +9 -1
  124. package/src/ai/planner.ts +9 -8
  125. package/src/ai/provider.ts +21 -3
  126. package/src/ai/researcher/deep-analysis.ts +1 -2
  127. package/src/ai/researcher.ts +4 -3
  128. package/src/ai/rules.ts +3 -3
  129. package/src/ai/tester.ts +40 -3
  130. package/src/ai/tools.ts +17 -9
  131. package/src/api/request-result.ts +10 -2
  132. package/src/api/request-store.ts +60 -13
  133. package/src/api/spec-reader.ts +1 -1
  134. package/src/commands/config-command.ts +1 -1
  135. package/src/commands/drill-command.ts +1 -1
  136. package/src/commands/explore-command.ts +37 -3
  137. package/src/commands/freesail-command.ts +7 -1
  138. package/src/commands/options/base-option.ts +18 -0
  139. package/src/commands/options/index.ts +7 -0
  140. package/src/commands/options/knowledge-option.ts +14 -0
  141. package/src/commands/options/ws-option.ts +24 -0
  142. package/src/commands/plans-command.ts +6 -6
  143. package/src/config.ts +12 -0
  144. package/src/experience-tracker.ts +5 -1
  145. package/src/explorbot.ts +21 -37
  146. package/src/knowledge-tracker.ts +94 -36
  147. package/src/remote.ts +0 -20
  148. package/src/state-manager.ts +13 -7
  149. package/src/test-plan.ts +29 -0
  150. package/src/utils/aria.ts +7 -44
  151. package/src/utils/html-diff.ts +62 -7
  152. package/src/utils/html.ts +14 -91
  153. package/src/utils/overlay.ts +226 -23
  154. package/src/utils/request-map.ts +19 -0
  155. package/src/utils/url-matcher.ts +3 -2
@@ -6,10 +6,9 @@ import { setActivity } from "../activity.js";
6
6
  import { Observability } from "../observability.js";
7
7
  import { Plan, Test, TestResult } from "../test-plan.js";
8
8
  import { collectInteractiveNodes } from "../utils/aria.js";
9
- import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, getComponentScopeHtmlExtractorSource, getVisibleOverlayHtmlExtractorSource, inferHtmlRole, } from "../utils/html.js";
9
+ import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from "../utils/html.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  import { loop, pause } from "../utils/loop.js";
12
- import { OVERLAY_SELECTORS } from "../utils/overlay.js";
13
12
  import { annotatePageElements } from "../utils/web-annotate.js";
14
13
  import { eidxInContainer } from "../utils/web-eidx.js";
15
14
  import { WebElement } from "../utils/web-element.js";
@@ -547,8 +546,12 @@ export class Driller extends TaskAgent {
547
546
  async detectNestedOverlayContext(component, result) {
548
547
  if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged)
549
548
  return null;
550
- const overlayHtml = await this.getVisibleOverlayHtml();
551
- if (!overlayHtml)
549
+ const parts = result.pageDiff.htmlParts ?? [];
550
+ let appeared = parts.filter((part) => part.added?.length > 0);
551
+ if (result.pageDiff.areaOfInterest)
552
+ appeared = parts;
553
+ const appearedHtml = appeared.map((part) => part.subtree).join('\n');
554
+ if (!appearedHtml)
552
555
  return null;
553
556
  const state = this.stateManager.getCurrentState();
554
557
  if (!state)
@@ -561,7 +564,7 @@ export class Driller extends TaskAgent {
561
564
  Keep the recorded code reusable and include the parent-opening action when the nested element requires the overlay to be open.
562
565
 
563
566
  <overlay_html>
564
- ${overlayHtml}
567
+ ${appearedHtml}
565
568
  </overlay_html>
566
569
 
567
570
  <current_page_aria>
@@ -570,21 +573,6 @@ export class Driller extends TaskAgent {
570
573
  </nested_overlay>
571
574
  `;
572
575
  }
573
- async getVisibleOverlayHtml() {
574
- return this.explorer.withPage((page) => page.evaluate(({ extractorSource, config }) => {
575
- const extract = new Function(`return ${extractorSource}`)();
576
- return extract(config);
577
- }, {
578
- extractorSource: getVisibleOverlayHtmlExtractorSource(),
579
- config: {
580
- interactiveContentSelector: HTML_SELECTORS.interactiveContent,
581
- limits: HTML_EXTRACTION_LIMITS,
582
- overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
583
- overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
584
- visibilityLimits: HTML_VISIBILITY_LIMITS,
585
- },
586
- }));
587
- }
588
576
  async getComponentScopeHtml(component, originalState) {
589
577
  const scopedHtml = await this.explorer.withPage((page) => page.evaluate(({ eidx, extractorSource, config }) => {
590
578
  const extract = new Function(`return ${extractorSource}`)();
@@ -74,6 +74,7 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
74
74
  statusText?: undefined;
75
75
  category?: undefined;
76
76
  errorPreview?: undefined;
77
+ extracted?: undefined;
77
78
  } | {
78
79
  success: boolean;
79
80
  status: number;
@@ -81,9 +82,11 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
81
82
  category: ResponseCategory;
82
83
  errorPreview: string;
83
84
  error?: undefined;
85
+ extracted?: undefined;
84
86
  } | {
85
87
  success: boolean;
86
88
  status: number;
89
+ extracted: Record<string, any>;
87
90
  error?: undefined;
88
91
  statusText?: undefined;
89
92
  category?: undefined;
@@ -102,6 +105,10 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
102
105
  }[];
103
106
  }, {
104
107
  finished: boolean;
108
+ error: string;
109
+ } | {
110
+ finished: boolean;
111
+ error?: undefined;
105
112
  }, import("@ai-sdk/provider-utils").Context>>;
106
113
  stop: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
107
114
  reason: any;
@@ -111,6 +118,7 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
111
118
  };
112
119
  getResult: () => FishermanResult;
113
120
  isFinished: () => boolean;
121
+ finishFromText: (text?: string) => void;
114
122
  };
115
123
  export interface FishermanResult {
116
124
  success: boolean;
@@ -119,6 +127,7 @@ export interface FishermanResult {
119
127
  type: string;
120
128
  id?: string | number;
121
129
  title?: string;
130
+ request?: string;
122
131
  }>;
123
132
  failed: Array<{
124
133
  type: string;
@@ -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)}');`);
@@ -522,7 +522,7 @@ class Navigator {
522
522
  return null;
523
523
  }
524
524
  const currentActionResult = actionResult || ActionResult.fromState(state);
525
- const research = Researcher.getCachedResearch(state) || '';
525
+ const research = Researcher.getCachedResearch(currentActionResult) || '';
526
526
  const combinedHtml = await currentActionResult.combinedHtml();
527
527
  const history = stateManager.getStateHistory();
528
528
  const visitCounts = new Map();
@@ -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).
@@ -96,11 +96,15 @@ export class Planner extends PlannerBase {
96
96
  Tests must be relevant to the page
97
97
  Tests must be achievable from UI
98
98
  Tests must be verifiable from UI
99
- NEVER split one workflow into multiple tests. Each test must be a complete end-to-end flow.
99
+ One test verifies ONE business operation: the steps that reach it, the action itself, and its verification.
100
+ Steps that only reach the action — opening a form, expanding a panel, creating or locating the item to act on — belong to that test. A second operation with its own verification does not.
100
101
  Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
101
102
  Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
102
103
  Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
103
- If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}${focusExistingDataDirective}
104
+ Bad: "Create a record, rename it, delete it" three verified operations, so THREE tests, not one.
105
+ Good: "Rename existing record and verify the new title" — ONE test; creating is skipped, we assume record already exists, only the rename is verified.
106
+ You may rely on another test having run first in case we deal with empty state and no relevant data was created yet and we expect another our test creates it
107
+ When the page reports a record is missing or unavailable, it is not a testable surface — plan list-level or recovery behavior instead of operations on that record.${featureDirective}${focusExistingDataDirective}
104
108
  </task>
105
109
 
106
110
  ${customPrompt || ''}
@@ -166,9 +170,6 @@ export class Planner extends PlannerBase {
166
170
  if (!aiResult?.object?.scenarios) {
167
171
  throw new Error('No tasks were created successfully');
168
172
  }
169
- if (aiResult.object.scenarios.length === 0 && !this.currentPlan) {
170
- throw new Error('No tasks were created successfully');
171
- }
172
173
  const defaultStartUrl = this.getDefaultStartUrl(state);
173
174
  const fromPlanning = aiResult.object.scenarios.map((s) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
174
175
  return { tests: fromPlanning, planName: aiResult.object.planName };
@@ -295,6 +296,7 @@ export class Planner extends PlannerBase {
295
296
  <task>
296
297
  Based on the page research, create ${this.MIN_TASKS}-${this.MAX_TASKS} exploratory testing scenarios.
297
298
  For each scenario provide specific steps and expected outcomes.
299
+ Exception: if the page reports the requested resource is missing, shows a failure state, or holds no content and no controls, return an empty scenarios list. Never invent tests for a page with nothing to exercise.
298
300
  </task>
299
301
 
300
302
  <rules>
@@ -308,13 +310,13 @@ export class Planner extends PlannerBase {
308
310
  Focus on error or success messages as outcome.
309
311
  Focus on URL page change or data persistency after page reload.
310
312
  If there are subpages (pages with same URL path) plan testing of those subpages as well
311
- If you plan to test CRUD operations, plan them in correct order: create, read, update.
313
+ Plan CRUD operations in order: create, read, update, delete.
312
314
  Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
313
315
  When validation placement or wording was not observed, require feedback associated with the invalid input without inventing a specific location or message.
314
316
  If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
315
317
  If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
316
318
  Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
317
- If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible or API preconditions can create it.
319
+ If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
318
320
  If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
319
321
  Do not assume hidden data exists just because a control is present.
320
322
  For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
@@ -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,9 @@ 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
+ if (state instanceof ActionResult)
57
+ return getCachedResearch(state.baseHash);
58
+ return getCachedResearch(ActionResult.fromState(state).baseHash);
57
59
  }
58
60
  getSystemMessage() {
59
61
  const currentUrl = this.stateManager.getCurrentState()?.url;
@@ -71,7 +73,7 @@ export class Researcher extends ResearcherBase {
71
73
  const maxRetries = this.config.ai?.agents?.researcher?.retries ?? 2;
72
74
  let retriesLeft = opts._retriesLeft ?? maxRetries;
73
75
  this.actionResult = ActionResult.fromState(state);
74
- const stateHash = state.hash || this.actionResult.getStateHash();
76
+ const stateHash = this.actionResult.baseHash;
75
77
  const researchState = { ...state, hash: stateHash };
76
78
  if (!force && stateHash) {
77
79
  const cached = getCachedResearch(stateHash);
@@ -223,7 +225,7 @@ export class Researcher extends ResearcherBase {
223
225
  }
224
226
  if (!interrupted() && deep) {
225
227
  try {
226
- await this.performDeepAnalysis(state, result);
228
+ await this.performDeepAnalysis(researchState, result);
227
229
  }
228
230
  catch (err) {
229
231
  tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
@@ -145,11 +145,11 @@ export const protectionRule = dedent `
145
145
 
146
146
  Pre-existing data on the page belongs to the application, not the test.
147
147
  Items that were not created inside the current test scenario must not be deleted, removed, emptied, reset, archived, or otherwise destroyed.
148
- If a scenario needs to verify destructive behaviour, the same scenario must first create a disposable target and then destroy that specific target — never operate on data that was already there when the test started.
148
+ If a scenario needs to verify destructive behaviour, the same scenario must first create its own target and then destroy that specific target — never operate on data that was already there when the test started.
149
149
 
150
150
  The resource that the current page URL represents is "under test".
151
151
  The test must not destroy the resource it is running against — doing so invalidates every subsequent scenario that starts on the same URL.
152
- Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on disposable children created within the scenario itself.
152
+ Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on children created within the scenario itself.
153
153
  </important>
154
154
  `;
155
155
  export const dataProtectionRules = dedent `
@@ -164,7 +164,7 @@ export const dataProtectionRules = dedent `
164
164
  filter, tab, or list-inspection constraint. Use visible existing data when it is available.
165
165
  If no suitable data exists, report the missing precondition instead of creating data.
166
166
 
167
- Destructive actions are allowed only against disposable data created by the current scenario
167
+ Destructive actions are allowed only against data created by the current scenario
168
168
  or prepared for that scenario by Fisherman/API preconditions. Existing application data must
169
169
  remain unchanged.
170
170
  </data_protection_rules>