explorbot 0.4.1 → 0.4.3

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 (141) hide show
  1. package/bin/explorbot-cli.ts +14 -4
  2. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  5. package/boat/doc-collector/src/ai/tools.ts +1 -1
  6. package/boat/doc-collector/src/cli.ts +18 -2
  7. package/boat/doc-collector/src/config.ts +3 -0
  8. package/boat/doc-collector/src/docbot.ts +56 -11
  9. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  10. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  11. package/boat/doc-collector/src/screenshots.ts +106 -3
  12. package/boat/doc-collector/src/template-dedup.ts +31 -0
  13. package/boat/prima/src/cli.ts +3 -0
  14. package/boat/prima/src/prima.ts +3 -4
  15. package/dist/bin/explorbot-cli.js +14 -4
  16. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  17. package/dist/boat/api-tester/src/cli.js +2 -0
  18. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  19. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  20. package/dist/boat/doc-collector/src/cli.js +15 -2
  21. package/dist/boat/doc-collector/src/config.js +1 -0
  22. package/dist/boat/doc-collector/src/docbot.js +52 -10
  23. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  24. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  25. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  26. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  27. package/dist/boat/prima/src/cli.js +2 -0
  28. package/dist/boat/prima/src/prima.js +3 -4
  29. package/dist/package.json +1 -1
  30. package/dist/src/action-result.d.ts +2 -2
  31. package/dist/src/action-result.js +15 -17
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +15 -15
  34. package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
  35. package/dist/src/ai/fisherman/request-haul.js +31 -0
  36. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  37. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
  38. package/dist/src/ai/fisherman.d.ts +14 -4
  39. package/dist/src/ai/fisherman.js +96 -18
  40. package/dist/src/ai/pilot.d.ts +13 -1
  41. package/dist/src/ai/pilot.js +40 -22
  42. package/dist/src/ai/provider.js +8 -14
  43. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  44. package/dist/src/ai/researcher/coordinates.js +13 -1
  45. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  46. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  47. package/dist/src/ai/researcher.js +1 -1
  48. package/dist/src/ai/rules.js +10 -0
  49. package/dist/src/ai/tester.js +20 -20
  50. package/dist/src/ai/tools.d.ts +1 -1
  51. package/dist/src/ai/tools.js +18 -74
  52. package/dist/src/api/request-result.js +3 -1
  53. package/dist/src/api/request-store.d.ts +7 -6
  54. package/dist/src/api/request-store.js +65 -37
  55. package/dist/src/api/xhr-capture.d.ts +2 -0
  56. package/dist/src/api/xhr-capture.js +35 -10
  57. package/dist/src/commands/config-command.js +2 -9
  58. package/dist/src/commands/help-json-command.d.ts +31 -0
  59. package/dist/src/commands/help-json-command.js +58 -0
  60. package/dist/src/commands/index.js +2 -0
  61. package/dist/src/commands/init-command.js +3 -7
  62. package/dist/src/commands/plans-command.d.ts +4 -1
  63. package/dist/src/commands/plans-command.js +4 -4
  64. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  65. package/dist/src/commands/recommended-models-command.js +85 -0
  66. package/dist/src/config.d.ts +2 -0
  67. package/dist/src/config.js +7 -2
  68. package/dist/src/experience-tracker.js +1 -1
  69. package/dist/src/explorbot.js +3 -3
  70. package/dist/src/global-config.d.ts +2 -0
  71. package/dist/src/global-config.js +6 -0
  72. package/dist/src/state-manager.d.ts +3 -3
  73. package/dist/src/state-manager.js +8 -8
  74. package/dist/src/test-plan.d.ts +1 -0
  75. package/dist/src/test-plan.js +13 -9
  76. package/dist/src/utils/aria.d.ts +1 -1
  77. package/dist/src/utils/aria.js +23 -2
  78. package/dist/src/utils/cli-section.d.ts +1 -0
  79. package/dist/src/utils/cli-section.js +12 -0
  80. package/dist/src/utils/html-diff.d.ts +1 -0
  81. package/dist/src/utils/html-diff.js +5 -5
  82. package/dist/src/utils/html.d.ts +2 -0
  83. package/dist/src/utils/html.js +5 -4
  84. package/dist/src/utils/overlay.d.ts +19 -55
  85. package/dist/src/utils/overlay.js +94 -148
  86. package/dist/src/utils/region.d.ts +41 -0
  87. package/dist/src/utils/region.js +99 -0
  88. package/dist/src/utils/similarity.d.ts +1 -0
  89. package/dist/src/utils/similarity.js +11 -0
  90. package/dist/src/utils/url-matcher.d.ts +1 -1
  91. package/dist/src/utils/url-matcher.js +4 -4
  92. package/docs/doc-collection/basics.md +11 -5
  93. package/docs/doc-collection/crawling.md +21 -1
  94. package/docs/reference/commands.md +4 -0
  95. package/docs/reference/configuration.md +4 -0
  96. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  97. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  98. package/docs/workflow/agentic-usage.md +12 -0
  99. package/docs/workflow/application-spec.md +4 -0
  100. package/package.json +1 -1
  101. package/src/action-result.ts +15 -16
  102. package/src/action.ts +16 -15
  103. package/src/ai/fisherman/request-haul.ts +36 -0
  104. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
  105. package/src/ai/fisherman.ts +110 -20
  106. package/src/ai/pilot.ts +38 -20
  107. package/src/ai/provider.ts +8 -12
  108. package/src/ai/researcher/coordinates.ts +13 -1
  109. package/src/ai/researcher/deep-analysis.ts +9 -9
  110. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  111. package/src/ai/researcher.ts +1 -1
  112. package/src/ai/rules.ts +10 -0
  113. package/src/ai/tester.ts +18 -18
  114. package/src/ai/tools.ts +18 -86
  115. package/src/api/request-result.ts +2 -1
  116. package/src/api/request-store.ts +68 -42
  117. package/src/api/xhr-capture.ts +39 -11
  118. package/src/commands/config-command.ts +2 -7
  119. package/src/commands/help-json-command.ts +74 -0
  120. package/src/commands/index.ts +2 -0
  121. package/src/commands/init-command.ts +4 -11
  122. package/src/commands/plans-command.ts +5 -5
  123. package/src/commands/recommended-models-command.ts +92 -0
  124. package/src/config.ts +9 -2
  125. package/src/experience-tracker.ts +1 -1
  126. package/src/explorbot.ts +3 -3
  127. package/src/global-config.ts +8 -0
  128. package/src/state-manager.ts +10 -9
  129. package/src/test-plan.ts +12 -10
  130. package/src/utils/aria.ts +26 -3
  131. package/src/utils/cli-section.ts +12 -0
  132. package/src/utils/html-diff.ts +6 -6
  133. package/src/utils/html.ts +6 -4
  134. package/src/utils/overlay.ts +105 -192
  135. package/src/utils/region.ts +132 -0
  136. package/src/utils/similarity.ts +9 -0
  137. package/src/utils/url-matcher.ts +3 -3
  138. package/dist/src/ai/fisherman-tools.d.ts +0 -138
  139. package/dist/src/utils/request-map.d.ts +0 -7
  140. package/dist/src/utils/request-map.js +0 -16
  141. package/src/utils/request-map.ts +0 -19
package/src/action.ts CHANGED
@@ -14,6 +14,7 @@ import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError }
14
14
  import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
15
15
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
16
  import { Overlay, OverlayPage } from './utils/overlay.js';
17
+ import type { Region } from './utils/region.js';
17
18
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
18
19
  import { safeFilename } from './utils/strings.ts';
19
20
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
@@ -181,7 +182,7 @@ class Action {
181
182
  focusedElement,
182
183
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
183
184
  });
184
- if (!frame) await this.detectRegionOfInterest(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
185
+ if (!frame) await this.detectRegion(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
185
186
  this.stateManager.updateState(result, codeBlock);
186
187
  return result;
187
188
  } catch (err) {
@@ -193,36 +194,36 @@ class Action {
193
194
  }
194
195
  }
195
196
 
196
- private async detectRegionOfInterest(result: ActionResult): Promise<void> {
197
+ private async detectRegion(result: ActionResult): Promise<void> {
197
198
  const previousState = this.stateManager.getCurrentState();
198
199
  if (!previousState) return;
199
200
  const previous = ActionResult.fromState(previousState);
200
- const previousOverlay = previous.overlay;
201
+ const previousRegion = previous.overlay;
201
202
  const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
202
203
  const overlayPage = new OverlayPage(this.playwrightHelper.page);
203
204
 
204
- if (result.overlay.detected && previousOverlay.detected && previousOverlay.root && previousOverlay.type === result.overlay.type && previousOverlay.name === result.overlay.name) {
205
- result.overlay = previousOverlay;
205
+ if (result.overlay.isModal && previousRegion.isModal && previousRegion.root && previousRegion.type === result.overlay.type && previousRegion.name === result.overlay.name) {
206
+ result.overlay = previousRegion;
206
207
  return;
207
208
  }
208
209
 
209
210
  if (!previous.html) return;
210
211
 
211
212
  if (previous.html === result.html) {
212
- if (sameUrl && previousOverlay.present && previousOverlay.xpath && !result.overlay.detected) result.overlay = previousOverlay;
213
+ if (sameUrl && previousRegion.isOpen && previousRegion.xpath && !result.overlay.isModal) result.overlay = previousRegion;
213
214
  return;
214
215
  }
215
216
 
216
- let carried: Overlay | null = null;
217
- if (sameUrl && previousOverlay.present && previousOverlay.xpath) {
218
- if (await overlayPage.isStillOpen(previousOverlay)) {
219
- carried = previousOverlay;
217
+ let carried: Region | null = null;
218
+ if (sameUrl && previousRegion.isOpen && previousRegion.xpath) {
219
+ if (await overlayPage.isStillOpen(previousRegion)) {
220
+ carried = previousRegion;
220
221
  } else {
221
- debugLog(`Region closed: ${previousOverlay.name || previousOverlay.type}`);
222
- if (!result.overlay.detected) {
223
- const parent = previousOverlay.parent;
222
+ debugLog(`Region closed: ${previousRegion.name || previousRegion.type}`);
223
+ if (!result.overlay.isModal) {
224
+ const parent = previousRegion.parent;
224
225
  if (parent?.xpath) {
225
- const restored = new Overlay(parent);
226
+ const restored = Overlay.resolve({ overlay: parent });
226
227
  if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
227
228
  }
228
229
  return;
@@ -239,7 +240,7 @@ class Action {
239
240
  previousHtml: previous.html,
240
241
  });
241
242
 
242
- if (result.overlay.detected) {
243
+ if (result.overlay instanceof Overlay) {
243
244
  if (detected) result.overlay = result.overlay.withGeometry(detected);
244
245
  return;
245
246
  }
@@ -0,0 +1,36 @@
1
+ import type { RequestResult } from '../../api/request-result.ts';
2
+ import { type RequestStore, isFailedRequest } from '../../api/request-store.ts';
3
+
4
+ export class RequestHaul {
5
+ private start: number;
6
+
7
+ constructor(private store: RequestStore) {
8
+ this.start = store.getMadeRequests().length;
9
+ }
10
+
11
+ requests(): RequestResult[] {
12
+ return this.store.getMadeRequests().slice(this.start);
13
+ }
14
+
15
+ failed(): RequestResult[] {
16
+ return this.requests().filter(isFailedRequest);
17
+ }
18
+
19
+ successfulWrites(): RequestResult[] {
20
+ return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
21
+ }
22
+
23
+ successfulReads(): RequestResult[] {
24
+ return this.requests().filter((r) => !r.isWrite && !r.error && r.status >= 200 && r.status < 400);
25
+ }
26
+
27
+ byId(): Map<string, RequestResult> {
28
+ const map = new Map<string, RequestResult>();
29
+ for (const request of this.successfulWrites()) {
30
+ const { id } = request.extractIdAndTitle();
31
+ if (id === undefined) continue;
32
+ map.set(String(id), request);
33
+ }
34
+ return map;
35
+ }
36
+ }
@@ -1,31 +1,36 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
- import type { ApiClient } from '../api/api-client.ts';
5
- import type { RequestResult } from '../api/request-result.ts';
6
- import type { RequestStore } from '../api/request-store.ts';
7
- import { extractEndpointDefinition } from '../api/spec-reader.ts';
8
- import { tag } from '../utils/logger.ts';
9
- import { RequestMap } from '../utils/request-map.ts';
10
- import { isDynamicSegment } from '../utils/url-matcher.ts';
11
-
12
- export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
4
+ import type { ApiClient } from '../../api/api-client.ts';
5
+ import type { RequestResult } from '../../api/request-result.ts';
6
+ import type { RequestStore } from '../../api/request-store.ts';
7
+ import { extractEndpointDefinition } from '../../api/spec-reader.ts';
8
+ import type { Test } from '../../test-plan.ts';
9
+ import { tag } from '../../utils/logger.ts';
10
+ import { isDynamicSegment } from '../../utils/url-matcher.ts';
11
+ import type { Fisherman } from '../fisherman.ts';
12
+ import type { RequestHaul } from './request-haul.ts';
13
+
14
+ const BODY_PREVIEW_LIMIT = 2000;
15
+
16
+ export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string; readOnly?: boolean }) {
17
+ const readOnly = opts.readOnly === true;
13
18
  let finished = false;
14
19
  let result: FishermanResult | null = null;
15
- const ledgerStart = requestStore.getMadeRequests().length;
16
20
 
17
- const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
18
- const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
19
- const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
21
+ let allowedMethods: string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
22
+ if (readOnly) allowedMethods = ['GET'];
23
+
24
+ const getResult = () => result ?? synthesizeResult(haul, false, readOnly);
20
25
  const isFinished = () => finished;
21
26
  const finishFromText = (text?: string) => {
22
27
  finished = true;
23
- const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
28
+ const synthesized = synthesizeResult(haul, true, readOnly);
24
29
  if (text && synthesized.success) synthesized.summary = text;
25
30
  result = synthesized;
26
31
  };
27
32
 
28
- const tools = {
33
+ const tools: Record<string, any> = {
29
34
  getEndpointSpec: tool({
30
35
  description: dedent`
31
36
  Get the request specification for an endpoint.
@@ -33,13 +38,14 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
33
38
  Call this before making a request to an endpoint you haven't used before.
34
39
  `,
35
40
  inputSchema: z.object({
36
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
41
+ method: z.enum(allowedMethods as [string, ...string[]]).describe('HTTP method'),
37
42
  path: z.string().describe('Endpoint path, e.g. /suites'),
38
43
  }),
39
44
  execute: async ({ method, path }) => {
40
45
  tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
41
46
 
42
- const captured = requestStore.findCapturedRequest(method, path);
47
+ let captured = requestStore.findCapturedRequest(method, path);
48
+ if (captured && !captured.requestBody && opts.spec && captured.status < 400) captured = undefined;
43
49
  if (captured) {
44
50
  if (captured.status >= 400) {
45
51
  const rejectedCapture = {
@@ -91,7 +97,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
91
97
  Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
92
98
  `,
93
99
  inputSchema: z.object({
94
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
100
+ method: z.enum(allowedMethods as [string, ...string[]]).describe('HTTP method'),
95
101
  path: z.string().describe('API path (e.g., /suites, /suites/1)'),
96
102
  body: z.any().optional().describe('Request body (JSON object)'),
97
103
  queryParams: z.record(z.string(), z.string()).optional().describe('Query parameters'),
@@ -128,11 +134,13 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
128
134
 
129
135
  const extracted = extractKeyFields(reqResult.responseBody);
130
136
  tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
131
- return {
137
+ const output: Record<string, any> = {
132
138
  success: true,
133
139
  status: reqResult.status,
134
140
  extracted,
135
141
  };
142
+ if (readOnly) output.bodyPreview = reqResult.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT);
143
+ return output;
136
144
  },
137
145
  }),
138
146
 
@@ -160,32 +168,12 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
160
168
  .describe('List of items that could not be created'),
161
169
  }),
162
170
  execute: async ({ summary, created, failed }) => {
163
- const writes = successfulWrites();
164
- if (writes.length === 0) {
165
- tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
166
- 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.' };
167
- }
168
-
169
- const createdRequests = new RequestMap(writes);
170
-
171
- const verified: FishermanResult['created'] = [];
172
- for (const item of created) {
173
- if (item.id === undefined) {
174
- verified.push(item);
175
- continue;
176
- }
177
- const request = createdRequests.get(item.id);
178
- if (!request) {
179
- tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
180
- continue;
181
- }
182
- verified.push({ ...item, request: request.toEndpoint() });
183
- }
184
- if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
171
+ const { result: verified, error } = verifyFinish(haul, { summary, created, failed });
172
+ if (!verified) return { finished: false, error };
185
173
 
186
174
  tag('success').log(`Fisherman done: ${summary}`);
187
175
  finished = true;
188
- result = { success: true, summary, created: verified, failed: failed || [] };
176
+ result = verified;
189
177
  return { finished: true };
190
178
  },
191
179
  }),
@@ -204,15 +192,106 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
204
192
  }),
205
193
  };
206
194
 
195
+ if (readOnly) {
196
+ tools.finish = tool({
197
+ description: 'Report the answer to the question. Call when the requests have shown what exists.',
198
+ inputSchema: z.object({
199
+ answer: z.string().describe('What the data shows, quoting the concrete names, titles and ids that were returned'),
200
+ }),
201
+ execute: async ({ answer }) => {
202
+ if (haul.successfulReads().length === 0) {
203
+ tag('warning').log('Fisherman: finish rejected — no successful request in this run');
204
+ return { finished: false, error: 'No successful request was made in this run, so nothing was read. Keep working, or call stop if the question cannot be answered.' };
205
+ }
206
+
207
+ tag('success').log(`Fisherman answered: ${answer}`);
208
+ finished = true;
209
+ result = { success: true, summary: answer, created: [], failed: [] };
210
+ return { finished: true };
211
+ },
212
+ });
213
+ }
214
+
207
215
  return { tools, getResult, isFinished, finishFromText };
208
216
  }
209
217
 
210
- function synthesizeResult(made: RequestResult[], writes: RequestResult[], declaredDone: boolean): FishermanResult {
211
- const failures = made.filter((r) => r.status >= 400 || r.error);
212
- let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
218
+ export function createAskApiTool(fisherman: Fisherman | null, task: Test) {
219
+ return {
220
+ askApi: tool({
221
+ description: dedent`
222
+ Ask what data already exists, changing nothing.
223
+ Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
224
+ Use it before precondition() to see whether suitable data is already available, and whenever a step needs the exact name or id of a record that is already there.
225
+ It never creates, edits or deletes anything — precondition() does that.
226
+ `,
227
+ inputSchema: z.object({
228
+ question: z.string().describe('What to find out about data that already exists'),
229
+ }),
230
+ execute: async ({ question }) => {
231
+ tag('info').log(`Ask API: ${question}`);
232
+
233
+ if (!fisherman?.isAvailable()) {
234
+ return { answered: false, reason: 'No API access is configured, so existing data cannot be queried. Judge from the page instead.' };
235
+ }
236
+
237
+ const result = await fisherman.lookupData(question, task.startUrl, task.sessionName);
238
+
239
+ if (!result.success) {
240
+ tag('warning').log(`Ask API unanswered: ${result.summary}`);
241
+ return { answered: false, reason: result.summary || 'The API could not answer this question' };
242
+ }
243
+
244
+ task.addNote(`Asked API: ${question} — ${result.summary}`);
245
+ tag('success').log(`Ask API: ${result.summary}`);
246
+ return { answered: true, answer: result.summary };
247
+ },
248
+ }),
249
+ };
250
+ }
251
+
252
+ export function verifyFinish(haul: RequestHaul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } {
253
+ const writes = haul.successfulWrites();
254
+ if (writes.length === 0) {
255
+ tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
256
+ return { result: null, 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.' };
257
+ }
258
+
259
+ const createdRequests = haul.byId();
260
+
261
+ const verified: FishermanResult['created'] = [];
262
+ for (const item of input.created) {
263
+ if (item.id === undefined) {
264
+ verified.push(item);
265
+ continue;
266
+ }
267
+ const request = createdRequests.get(String(item.id));
268
+ if (!request) {
269
+ tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
270
+ continue;
271
+ }
272
+ verified.push({ ...item, request: request.toEndpoint() });
273
+ }
274
+ if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
275
+
276
+ return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
277
+ }
278
+
279
+ function synthesizeResult(haul: RequestHaul, declaredDone: boolean, readOnly: boolean): FishermanResult {
280
+ const made = haul.requests();
281
+ const failures = haul.failed();
282
+ let succeeded = haul.successfulWrites();
283
+ let successLabel = 'successful writes';
284
+ if (readOnly) {
285
+ succeeded = haul.successfulReads();
286
+ successLabel = 'successful reads';
287
+ }
288
+ let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
213
289
  const lastFailure = failures[failures.length - 1];
214
290
  if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
215
- return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
291
+
292
+ const result: FishermanResult = { success: declaredDone && succeeded.length > 0, summary, created: [], failed: [] };
293
+ if (!readOnly) result.created = succeeded.map(toCreatedItem);
294
+ return result;
216
295
  }
217
296
 
218
297
  function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
@@ -1,13 +1,15 @@
1
1
  import dedent from 'dedent';
2
2
  import type { ApiClient } from '../api/api-client.ts';
3
- import type { RequestStore } from '../api/request-store.ts';
3
+ import { type EndpointFamily, type RequestStore, isFailedRequest } from '../api/request-store.ts';
4
4
  import { listAllEndpoints } from '../api/spec-reader.ts';
5
5
  import { createDebug, tag } from '../utils/logger.ts';
6
6
 
7
7
  const debugLog = createDebug('explorbot:fisherman');
8
8
  import { loop } from '../utils/loop.ts';
9
9
  import type { Agent } from './agent.ts';
10
- import { type FishermanResult, createFishermanTools } from './fisherman-tools.ts';
10
+ import type { Conversation } from './conversation.ts';
11
+ import { RequestHaul } from './fisherman/request-haul.ts';
12
+ import { type FishermanResult, createFishermanTools } from './fisherman/tools.ts';
11
13
  import type { Provider } from './provider.ts';
12
14
  import { dataProtectionRules } from './rules.ts';
13
15
 
@@ -72,22 +74,74 @@ export class Fisherman implements Agent {
72
74
 
73
75
  if (!endpointList) {
74
76
  tag('warning').log('Fisherman: no endpoints available');
75
- this.mode = 'disabled';
76
77
  return { success: false, summary: 'No API endpoints available', created: [], failed: [] };
77
78
  }
78
79
 
79
80
  await this.refreshAuth();
80
81
  debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
81
82
 
82
- const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
83
+ const haul = new RequestHaul(this.requestStore);
84
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
83
85
  spec: this.spec,
84
86
  baseEndpoint: this.baseEndpoint,
85
87
  });
86
- const ledgerStart = this.requestStore.getMadeRequests().length;
87
88
 
88
89
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
89
90
  conversation.addUserText(this.buildTaskPrompt(instructions));
90
91
 
92
+ await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman: ${instructions.slice(0, 50)}` });
93
+
94
+ const result = getResult();
95
+ tag('info').log(`Fisherman result: ${result.summary}`);
96
+ return result;
97
+ }
98
+
99
+ async lookupData(question: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult> {
100
+ this.sessionName = sessionName;
101
+ tag('info').log(`Fisherman [read]: ${question}`);
102
+
103
+ await this.ensureReady(scopeUrl);
104
+
105
+ if (this.mode === 'disabled') {
106
+ debugLog('disabled — no data for scope');
107
+ return { success: false, summary: 'No API data available for this scope', created: [], failed: [] };
108
+ }
109
+
110
+ const endpointList = this.buildEndpointList(scopeUrl, 'read');
111
+ debugLog(`read endpoints:\n${endpointList || '(none)'}`);
112
+
113
+ if (!endpointList) {
114
+ tag('warning').log('Fisherman: no read endpoints available');
115
+ return { success: false, summary: 'No read endpoints are known for this scope', created: [], failed: [] };
116
+ }
117
+
118
+ await this.refreshAuth();
119
+
120
+ const haul = new RequestHaul(this.requestStore);
121
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
122
+ spec: this.spec,
123
+ baseEndpoint: this.baseEndpoint,
124
+ readOnly: true,
125
+ });
126
+
127
+ const conversation = this.provider.startConversation(this.buildLookupSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
128
+ conversation.addUserText(dedent`
129
+ Answer this question about data that already exists:
130
+
131
+ ${question}
132
+
133
+ Make the requests needed to answer it, then call finish with the answer.
134
+ If the available endpoints cannot answer it, call stop with the reason.
135
+ `);
136
+
137
+ await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
138
+
139
+ const result = getResult();
140
+ tag('info').log(`Fisherman answer: ${result.summary}`);
141
+ return result;
142
+ }
143
+
144
+ private async runSession(conversation: Conversation, tools: Record<string, any>, opts: { haul: RequestHaul; isFinished: () => boolean; finishFromText: (text?: string) => void; label: string }): Promise<void> {
91
145
  await loop(
92
146
  async ({ stop, iteration }) => {
93
147
  debugLog(`iteration ${iteration}`);
@@ -97,19 +151,19 @@ export class Fisherman implements Agent {
97
151
  });
98
152
  debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
99
153
 
100
- if (isFinished()) {
154
+ if (opts.isFinished()) {
101
155
  stop();
102
156
  return;
103
157
  }
104
158
 
105
159
  if (!invokeResult?.toolExecutions?.length) {
106
160
  debugLog('no tool call in this turn — treating as finish');
107
- finishFromText(invokeResult?.response?.text);
161
+ opts.finishFromText(invokeResult?.response?.text);
108
162
  stop();
109
163
  return;
110
164
  }
111
165
 
112
- if (this.isStuckOnEndpoint(ledgerStart)) {
166
+ if (this.isStuckOnEndpoint(opts.haul)) {
113
167
  tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
114
168
  stop();
115
169
  return;
@@ -123,7 +177,7 @@ export class Fisherman implements Agent {
123
177
  {
124
178
  maxAttempts: MAX_ITERATIONS,
125
179
  observability: {
126
- name: `fisherman: ${instructions.slice(0, 50)}`,
180
+ name: opts.label,
127
181
  agent: 'fisherman',
128
182
  sessionId: this.sessionName,
129
183
  },
@@ -134,10 +188,6 @@ export class Fisherman implements Agent {
134
188
  },
135
189
  }
136
190
  );
137
-
138
- const result = getResult();
139
- tag('info').log(`Fisherman result: ${result.summary}`);
140
- return result;
141
191
  }
142
192
 
143
193
  private async detectMode(scopeUrl?: string): Promise<void> {
@@ -177,18 +227,19 @@ export class Fisherman implements Agent {
177
227
  }
178
228
  }
179
229
 
180
- private buildEndpointList(scopeUrl?: string): string {
230
+ private buildEndpointList(scopeUrl?: string, family: EndpointFamily = 'write'): string {
181
231
  this.scopeDegraded = false;
182
232
  if (this.mode === 'achieve' && this.spec) {
183
- const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
233
+ let specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
234
+ if (family === 'read') specEndpoints = keepReadLines(specEndpoints);
184
235
  if (specEndpoints) return specEndpoints;
185
236
  }
186
237
 
187
- const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
238
+ const scoped = this.requestStore.toEndpointList(scopeUrl || '/', family);
188
239
  if (scoped) return scoped;
189
240
 
190
241
  this.scopeDegraded = true;
191
- return this.requestStore.toEndpointList();
242
+ return this.requestStore.toEndpointList(undefined, family);
192
243
  }
193
244
 
194
245
  private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
@@ -228,12 +279,44 @@ export class Fisherman implements Agent {
228
279
  `;
229
280
  }
230
281
 
231
- private isStuckOnEndpoint(ledgerStart: number): boolean {
232
- const made = this.requestStore.getMadeRequests().slice(ledgerStart);
282
+ private buildLookupSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
283
+ let scopeBlock = '';
284
+ if (scopeUrl) {
285
+ scopeBlock = `\n\nSCOPE: You are answering about ${scopeUrl}.`;
286
+ if (this.scopeDegraded) scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Prefer the endpoint whose path belongs to this scope.';
287
+ }
288
+
289
+ return dedent`
290
+ You are Fisherman — reading the API to report what data already exists. You change nothing.
291
+
292
+ AVAILABLE ENDPOINTS:
293
+ ${endpointList}
294
+ ${scopeBlock}
295
+
296
+ AVAILABLE TOOLS:
297
+ ${toolNames.join(', ')}.
298
+ Use tool names exactly as listed. Do not invent aliases or combined names.
299
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
300
+
301
+ WORKFLOW:
302
+ 1. Pick the endpoint that lists the kind of item the question is about
303
+ 2. Request it, and when the answer needs a parent resource, request the parent first and use its id
304
+ 3. Call finish with the answer, quoting the concrete names, titles and ids the responses returned
305
+
306
+ RULES:
307
+ - Report only what a response actually returned. Never describe data you did not read
308
+ - Report an empty collection as empty. An absent item must not be reported as present
309
+ - Answer the question that was asked and stop. Do not survey unrelated endpoints
310
+ - Use the response category and error text to correct a failed request. Retry a temporary or server failure once
311
+ `;
312
+ }
313
+
314
+ private isStuckOnEndpoint(haul: RequestHaul): boolean {
315
+ const made = haul.requests();
233
316
  if (made.length < REPEATED_FAILURE_LIMIT) return false;
234
317
  const recent = made.slice(-REPEATED_FAILURE_LIMIT);
235
318
  const first = recent[0];
236
- return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
319
+ return recent.every((r) => isFailedRequest(r) && r.method === first.method && r.path === first.path);
237
320
  }
238
321
 
239
322
  private buildTaskPrompt(instructions: string): string {
@@ -249,3 +332,10 @@ export class Fisherman implements Agent {
249
332
  `;
250
333
  }
251
334
  }
335
+
336
+ function keepReadLines(endpointList: string): string {
337
+ return endpointList
338
+ .split('\n')
339
+ .filter((line) => line.startsWith('GET '))
340
+ .join('\n');
341
+ }