explorbot 0.4.2 → 0.4.4

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 (74) hide show
  1. package/bin/explorbot-cli.ts +6 -1
  2. package/boat/api-tester/src/apibot.ts +8 -13
  3. package/boat/api-tester/src/cli.ts +7 -3
  4. package/boat/api-tester/src/config.ts +45 -9
  5. package/boat/prima/src/cli.ts +33 -99
  6. package/boat/prima/src/envelope.ts +3 -1
  7. package/boat/prima/src/help.ts +72 -0
  8. package/boat/prima/src/prima.ts +33 -43
  9. package/dist/bin/explorbot-cli.js +5 -1
  10. package/dist/boat/api-tester/src/apibot.js +7 -6
  11. package/dist/boat/api-tester/src/cli.js +9 -3
  12. package/dist/boat/api-tester/src/config.js +32 -6
  13. package/dist/boat/prima/src/cli.js +30 -86
  14. package/dist/boat/prima/src/envelope.js +2 -1
  15. package/dist/boat/prima/src/help.js +63 -0
  16. package/dist/boat/prima/src/prima.js +29 -41
  17. package/dist/package.json +1 -1
  18. package/dist/src/action-result.d.ts +3 -0
  19. package/dist/src/action-result.js +5 -0
  20. package/dist/src/action.js +12 -1
  21. package/dist/src/ai/fisherman/request-haul.d.ts +1 -0
  22. package/dist/src/ai/fisherman/request-haul.js +3 -0
  23. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  24. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +78 -13
  25. package/dist/src/ai/fisherman.d.ts +12 -3
  26. package/dist/src/ai/fisherman.js +89 -13
  27. package/dist/src/ai/pilot.d.ts +13 -1
  28. package/dist/src/ai/pilot.js +20 -7
  29. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  30. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  31. package/dist/src/ai/researcher/sections.d.ts +1 -1
  32. package/dist/src/ai/researcher/sections.js +2 -1
  33. package/dist/src/ai/researcher.js +25 -11
  34. package/dist/src/ai/rules.js +2 -0
  35. package/dist/src/ai/tester.d.ts +1 -0
  36. package/dist/src/ai/tester.js +27 -33
  37. package/dist/src/ai/tools.js +5 -0
  38. package/dist/src/api/request-result.js +3 -1
  39. package/dist/src/api/request-store.d.ts +6 -1
  40. package/dist/src/api/request-store.js +55 -17
  41. package/dist/src/api/xhr-capture.d.ts +2 -0
  42. package/dist/src/api/xhr-capture.js +35 -10
  43. package/dist/src/commands/config-command.js +6 -2
  44. package/dist/src/commands/help-json-command.d.ts +31 -0
  45. package/dist/src/commands/help-json-command.js +58 -0
  46. package/dist/src/config.d.ts +3 -0
  47. package/dist/src/config.js +14 -0
  48. package/dist/src/state-manager.js +5 -1
  49. package/docs/api-testing/basics.md +12 -4
  50. package/docs/reference/commands.md +2 -0
  51. package/docs/reference/configuration.md +4 -0
  52. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  53. package/docs/workflow/agentic-usage.md +15 -1
  54. package/package.json +1 -1
  55. package/src/action-result.ts +7 -0
  56. package/src/action.ts +14 -2
  57. package/src/ai/fisherman/request-haul.ts +4 -0
  58. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +93 -20
  59. package/src/ai/fisherman.ts +104 -15
  60. package/src/ai/pilot.ts +20 -7
  61. package/src/ai/researcher/deep-analysis.ts +4 -2
  62. package/src/ai/researcher/sections.ts +2 -2
  63. package/src/ai/researcher.ts +28 -11
  64. package/src/ai/rules.ts +2 -0
  65. package/src/ai/tester.ts +25 -30
  66. package/src/ai/tools.ts +6 -0
  67. package/src/api/request-result.ts +2 -1
  68. package/src/api/request-store.ts +58 -18
  69. package/src/api/xhr-capture.ts +39 -11
  70. package/src/commands/config-command.ts +4 -1
  71. package/src/commands/help-json-command.ts +74 -0
  72. package/src/config.ts +16 -0
  73. package/src/state-manager.ts +6 -1
  74. package/dist/src/ai/fisherman-tools.d.ts +0 -147
@@ -0,0 +1,63 @@
1
+ import dedent from 'dedent';
2
+ export const helpContract = dedent `
3
+ Prima drives the browser opened by playwright-cli.
4
+
5
+ playwright-cli open <url> starts the session
6
+ prima <command> ... drives it
7
+ playwright-cli close ends it
8
+
9
+ One call runs a whole job:
10
+
11
+ prima check "a workflow can be created and appears in the list" --expected "the new workflow is listed"
12
+ prima do "open the account menu" "choose the settings entry" "switch the theme to dark" "check it took effect"
13
+ prima pw "({ page }) => page.click('[data-test=submit]')"
14
+
15
+ Only research maps a page. Other commands read the accessibility tree; a cached
16
+ research map joins when present. On large or unclear pages, run prima research first.
17
+ Without a usable AI model only pw works; otherwise use playwright-cli.
18
+ DEBUG='explorbot:*' in front of a command logs everything it does.
19
+ `;
20
+ export const checkHelp = dedent `
21
+ check states the outcome, not the clicks; it finds the path itself. It stays
22
+ on the current page and never reloads it, so an open dialog survives.
23
+ --expected one required outcome, repeatable for several. Without it the
24
+ scenario text is the outcome. Each returns under
25
+ ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
26
+ "not verified" means never checked, not false.
27
+ Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
28
+ CONTRADICTION means screenshot and log disagree; judge the html, aria and
29
+ screenshot under ### Artifacts yourself.
30
+ ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
31
+ reported as such rather than as an app failure.
32
+ Side issues found on the way go under ### Answer, not as step failures.
33
+ `;
34
+ export const doHelp = dedent `
35
+ ### Steps marks each instruction ok, FAIL or ??. ?? means it ran but the run ended
36
+ without confirming it - read the steps above. Only FAIL fails the command.
37
+ Nothing runs past the last instruction. Batch the whole sequence in one call;
38
+ that is what keeps this tier cheap.
39
+ `;
40
+ export const askHelp = dedent `
41
+ Answers from a page screenshot, or from its structure with --no-vision.
42
+ `;
43
+ export const verifyHelp = dedent `
44
+ Reports each expressible assertion as PASSED or FAILED with its playwright form;
45
+ no overall verdict, read the lines. "none ran" means unexpressible, not false.
46
+ `;
47
+ export const researchHelp = dedent `
48
+ The map is saved per page state and joins later commands there, so one research run pays for all that follow it.
49
+ `;
50
+ export const statusHelp = dedent `
51
+ Reads recorded files, so it needs no browser and outlives the session.
52
+ The hash is matched across all recorded sites. ### Artifacts lists every kept
53
+ file: aria, html, screenshot and network log when captured, plus per-step captures of a do run.
54
+ `;
55
+ export const reportHelp = dedent `
56
+ Built from the command log, so it needs no browser and outlives the session.
57
+ Reports the latest session unless --pw-session names another.
58
+ `;
59
+ export const sessionHelp = dedent `
60
+ Parallel jobs need one --instance each. --session is ignored when attached,
61
+ since the attached session keeps its own. --framework is parsed but inactive;
62
+ reported code is CodeceptJS either way.
63
+ `;
@@ -23,14 +23,13 @@ import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
23
23
  import { pluralize } from "../../../src/utils/logger.js";
24
24
  import { mdq } from "../../../src/utils/markdown-query.js";
25
25
  import { safeFilename } from "../../../src/utils/strings.js";
26
- import { STATUS_FILE, readArtifacts, writeArtifacts } from "./envelope.js";
26
+ import { STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from "./envelope.js";
27
27
  import { isFunctionExpression, takePwValue, toCodeceptWrapper } from "./pw-parser.js";
28
28
  import { readDescriptors, selectDescriptor } from "./pw-registry.js";
29
29
  import { latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from "./session-log.js";
30
- const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
30
+ const WITHHELD_TOOLS = ['learnExperience', 'askUser', 'research'];
31
31
  const ITERATIONS_PER_INSTRUCTION = 2;
32
32
  const MAX_INSTRUCTION_ITERATIONS = 24;
33
- const DEFAULT_RESEARCH_AFTER_VISITS = 3;
34
33
  const CONTEXT_HTML_CAP = 6000;
35
34
  const MAX_TOOL_ROUNDTRIPS = 5;
36
35
  const AI_AGENT_NAME = 'prima';
@@ -68,7 +67,6 @@ export class Prima {
68
67
  server = null;
69
68
  attached = null;
70
69
  session = null;
71
- artifacts;
72
70
  constructor(options = {}) {
73
71
  this.options = options;
74
72
  this.bot = new ExplorBot({
@@ -99,6 +97,7 @@ export class Prima {
99
97
  const config = await this.loadConfig();
100
98
  await this.resolveBrowser(config, discovery);
101
99
  await this.bot.start();
100
+ this.bot.agentResearcher().disable();
102
101
  if (!this.options.url)
103
102
  return;
104
103
  if (this.bot.getCurrentState())
@@ -382,9 +381,11 @@ export class Prima {
382
381
  const guard = await this.aiGuard(command);
383
382
  if (guard)
384
383
  return guard;
384
+ const researcher = this.bot.agentResearcher();
385
+ researcher.enable();
385
386
  const previousState = this.bot.stateManager().getCurrentState();
386
387
  const result = await this.capturedResult(previousState);
387
- const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
388
+ const uiMap = await researcher.research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
388
389
  return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
389
390
  }
390
391
  async go(target) {
@@ -768,7 +769,7 @@ export class Prima {
768
769
  if (!researcher || !navigator)
769
770
  return {};
770
771
  const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
771
- for (const name of TESTER_ONLY_TOOLS)
772
+ for (const name of WITHHELD_TOOLS)
772
773
  delete tools[name];
773
774
  return tools;
774
775
  }
@@ -823,14 +824,15 @@ export class Prima {
823
824
  }
824
825
  async pageContext(result) {
825
826
  const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
826
- const map = this.researchMap(result);
827
+ const map = getPreviousResearch(result.baseHash);
828
+ let uiMap = '';
827
829
  if (map) {
828
- return dedent `
829
- <page_ui_map url="${result.url}" title="${result.title}">
830
+ uiMap = dedent `
831
+ <page_ui_map>
832
+ A map of this page recorded by an earlier research run. It names parts the accessibility
833
+ tree does not, and can be out of date — the tree is what the page holds now.
830
834
  ${map}
831
835
  </page_ui_map>
832
-
833
- ${experience}
834
836
  `;
835
837
  }
836
838
  return dedent `
@@ -838,20 +840,11 @@ export class Prima {
838
840
  ${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
839
841
  </page>
840
842
 
843
+ ${uiMap}
844
+
841
845
  ${experience}
842
846
  `;
843
847
  }
844
- researchMap(result) {
845
- if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits())
846
- return '';
847
- return getPreviousResearch(result.getStateHash());
848
- }
849
- researchAfterVisits() {
850
- const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
851
- if (typeof configured === 'number')
852
- return configured;
853
- return DEFAULT_RESEARCH_AFTER_VISITS;
854
- }
855
848
  offloadValue(value) {
856
849
  const dir = this.statusDir();
857
850
  const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
@@ -923,16 +916,13 @@ export class Prima {
923
916
  }
924
917
  async successEnvelope(command, used, result, previousState) {
925
918
  const changes = await this.pageChanges(result, previousState, used[0]);
926
- const status = await this.saveStatus(result);
927
919
  return {
928
920
  ok: true,
929
921
  command,
930
922
  used,
931
923
  page: this.pageBlock(result, previousState),
932
924
  changes,
933
- instance: await this.instanceInfo(),
934
- status,
935
- artifacts: this.artifacts,
925
+ ...(await this.envelopeTail(result)),
936
926
  };
937
927
  }
938
928
  async failureEnvelope(command, error, previousState) {
@@ -940,29 +930,27 @@ export class Prima {
940
930
  const failure = { error: browserErrorMessage(error) };
941
931
  if (result.ariaSnapshot)
942
932
  failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
943
- const status = await this.saveStatus(result);
944
933
  return {
945
934
  ok: false,
946
935
  command,
947
936
  page: this.pageBlock(result, previousState),
948
937
  failure,
949
- instance: await this.instanceInfo(),
950
- status,
951
- artifacts: this.artifacts,
938
+ ...(await this.envelopeTail(result)),
952
939
  };
953
940
  }
954
941
  async reportEnvelope(command, result, previousState, outcome) {
955
- const status = await this.saveStatus(result);
956
942
  return {
957
943
  ok: true,
958
944
  command,
959
945
  page: this.pageBlock(result, previousState),
960
946
  ...outcome,
961
- instance: await this.instanceInfo(),
962
- status,
963
- artifacts: this.artifacts,
947
+ ...(await this.envelopeTail(result)),
964
948
  };
965
949
  }
950
+ async envelopeTail(result) {
951
+ const { hash, artifacts } = await this.saveStatus(result);
952
+ return { instance: await this.instanceInfo(), status: hash, artifacts };
953
+ }
966
954
  async capturedResult(previousState, opts = {}) {
967
955
  const captured = await this.bot
968
956
  .getExplorer()
@@ -1026,9 +1014,9 @@ export class Prima {
1026
1014
  }
1027
1015
  async saveStatus(result) {
1028
1016
  const hash = this.statusHash();
1029
- await this.writeSnapshot(result);
1017
+ const artifacts = await this.writeSnapshot(result);
1030
1018
  writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1031
- return hash;
1019
+ return { hash, artifacts };
1032
1020
  }
1033
1021
  async writeStepFiles(index, label, diff) {
1034
1022
  const state = this.bot.stateManager().getCurrentState();
@@ -1038,17 +1026,17 @@ export class Prima {
1038
1026
  mkdirSync(dir, { recursive: true });
1039
1027
  const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
1040
1028
  const result = ActionResult.fromState(state);
1041
- writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
1042
- writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
1029
+ writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8');
1030
+ writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8');
1043
1031
  if (diff)
1044
- writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
1032
+ writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8');
1045
1033
  }
1046
1034
  async writeSnapshot(result) {
1047
- this.artifacts = writeArtifacts(this.statusDir(), {
1035
+ return writeArtifacts(this.statusDir(), {
1048
1036
  aria: result.ariaSnapshot,
1049
1037
  html: await result.combinedHtml(),
1050
1038
  screenshot: result.screenshot,
1051
- requests: this.bot.requestStore().getMadeRequests(),
1039
+ requests: this.bot.requestStore().getCapturedRequests(),
1052
1040
  });
1053
1041
  }
1054
1042
  statusHash() {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -26,6 +26,7 @@ interface ActionResultData extends WebPageState {
26
26
  }>;
27
27
  ariaSnapshot?: string | null;
28
28
  ariaSnapshotFile?: string;
29
+ regionAria?: string | null;
29
30
  focusedElement?: FocusedElement | null;
30
31
  iframeURL?: string;
31
32
  links?: Link[];
@@ -84,6 +85,7 @@ export declare class ActionResult implements ActionResultData {
84
85
  links: Link[];
85
86
  verifications?: Record<string, boolean>;
86
87
  overlay: Region;
88
+ regionAria: string | null;
87
89
  _diffCache: {
88
90
  previousId: number | undefined;
89
91
  diff: Diff;
@@ -109,6 +111,7 @@ export declare class ActionResult implements ActionResultData {
109
111
  }): Promise<string>;
110
112
  textHtml(htmlConfig?: HtmlConfig): Promise<string>;
111
113
  getInteractiveARIA(): string;
114
+ getRegionARIA(): string;
112
115
  getCompactARIA(): string;
113
116
  normalizeHtmlConfig(htmlConfig?: HtmlConfig): HtmlConfig | undefined;
114
117
  static fromState(state: WebPageState): ActionResult;
@@ -40,6 +40,7 @@ export class ActionResult {
40
40
  links = [];
41
41
  verifications;
42
42
  overlay = new Region();
43
+ regionAria = null;
43
44
  _diffCache = null;
44
45
  constructor(data) {
45
46
  this.id = data.id;
@@ -55,6 +56,7 @@ export class ActionResult {
55
56
  this.iframeURL = data.iframeURL;
56
57
  this.notes = data.notes ?? [];
57
58
  this.verifications = data.verifications;
59
+ this.regionAria = data.regionAria ?? null;
58
60
  // Set readonly properties
59
61
  if (data.screenshotFile !== undefined) {
60
62
  this.screenshotFile = data.screenshotFile;
@@ -238,6 +240,9 @@ export class ActionResult {
238
240
  getInteractiveARIA() {
239
241
  return compactAriaSnapshot(this.ariaSnapshot, false);
240
242
  }
243
+ getRegionARIA() {
244
+ return compactAriaSnapshot(this.regionAria, false);
245
+ }
241
246
  getCompactARIA() {
242
247
  return compactAriaSnapshot(this.ariaSnapshot, true);
243
248
  }
@@ -165,8 +165,19 @@ class Action {
165
165
  focusedElement,
166
166
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
167
167
  });
168
- if (!frame)
168
+ if (!frame) {
169
169
  await this.detectRegion(result).catch((err) => debugLog('Region detection failed:', err.message));
170
+ const regionRoot = result.overlay.root;
171
+ if (result.overlay.isModal && regionRoot) {
172
+ result.regionAria = await this.playwrightHelper.page
173
+ .locator(regionRoot)
174
+ .ariaSnapshot()
175
+ .catch((err) => {
176
+ debugLog('Region ARIA snapshot failed:', err.message);
177
+ return null;
178
+ });
179
+ }
180
+ }
170
181
  this.stateManager.updateState(result, codeBlock);
171
182
  return result;
172
183
  }
@@ -7,5 +7,6 @@ export declare class RequestHaul {
7
7
  requests(): RequestResult[];
8
8
  failed(): RequestResult[];
9
9
  successfulWrites(): RequestResult[];
10
+ successfulReads(): RequestResult[];
10
11
  byId(): Map<string, RequestResult>;
11
12
  }
@@ -15,6 +15,9 @@ export class RequestHaul {
15
15
  successfulWrites() {
16
16
  return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
17
17
  }
18
+ successfulReads() {
19
+ return this.requests().filter((r) => !r.isWrite && !r.error && r.status >= 200 && r.status < 400);
20
+ }
18
21
  byId() {
19
22
  const map = new Map();
20
23
  for (const request of this.successfulWrites()) {
@@ -0,0 +1,50 @@
1
+ import type { ApiClient } from '../../api/api-client.js';
2
+ import type { RequestStore } from '../../api/request-store.js';
3
+ import type { Test } from '../../test-plan.js';
4
+ import type { Fisherman } from '../fisherman.js';
5
+ import type { RequestHaul } from './request-haul.js';
6
+ export declare function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: {
7
+ spec?: any;
8
+ baseEndpoint?: string;
9
+ readOnly?: boolean;
10
+ }): {
11
+ tools: Record<string, any>;
12
+ getResult: () => FishermanResult;
13
+ isFinished: () => boolean;
14
+ finishFromText: (text?: string) => void;
15
+ };
16
+ export declare function createAskApiTool(fisherman: Fisherman | null, task: Test): {
17
+ askApi: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
18
+ question: any;
19
+ }, {
20
+ answered: boolean;
21
+ reason: string;
22
+ answer?: undefined;
23
+ } | {
24
+ answered: boolean;
25
+ answer: string;
26
+ reason?: undefined;
27
+ }, import("@ai-sdk/provider-utils").Context>>;
28
+ };
29
+ export declare function verifyFinish(haul: RequestHaul, input: {
30
+ summary: string;
31
+ created: FishermanResult['created'];
32
+ failed?: FishermanResult['failed'];
33
+ }): {
34
+ result: FishermanResult | null;
35
+ error?: string;
36
+ };
37
+ export interface FishermanResult {
38
+ success: boolean;
39
+ summary: string;
40
+ created: Array<{
41
+ type: string;
42
+ id?: string | number;
43
+ title?: string;
44
+ request?: string;
45
+ }>;
46
+ failed: Array<{
47
+ type: string;
48
+ reason: string;
49
+ }>;
50
+ }
@@ -1,17 +1,22 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
- import { extractEndpointDefinition } from "../api/spec-reader.js";
5
- import { tag } from "../utils/logger.js";
6
- import { isDynamicSegment } from "../utils/url-matcher.js";
4
+ import { extractEndpointDefinition } from "../../api/spec-reader.js";
5
+ import { tag } from "../../utils/logger.js";
6
+ import { isDynamicSegment } from "../../utils/url-matcher.js";
7
+ const BODY_PREVIEW_LIMIT = 2000;
7
8
  export function createFishermanTools(apiClient, requestStore, haul, opts) {
9
+ const readOnly = opts.readOnly === true;
8
10
  let finished = false;
9
11
  let result = null;
10
- const getResult = () => result ?? synthesizeResult(haul, false);
12
+ let allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
13
+ if (readOnly)
14
+ allowedMethods = ['GET'];
15
+ const getResult = () => result ?? synthesizeResult(haul, false, readOnly);
11
16
  const isFinished = () => finished;
12
17
  const finishFromText = (text) => {
13
18
  finished = true;
14
- const synthesized = synthesizeResult(haul, true);
19
+ const synthesized = synthesizeResult(haul, true, readOnly);
15
20
  if (text && synthesized.success)
16
21
  synthesized.summary = text;
17
22
  result = synthesized;
@@ -24,12 +29,14 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
24
29
  Call this before making a request to an endpoint you haven't used before.
25
30
  `,
26
31
  inputSchema: z.object({
27
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
32
+ method: z.enum(allowedMethods).describe('HTTP method'),
28
33
  path: z.string().describe('Endpoint path, e.g. /suites'),
29
34
  }),
30
35
  execute: async ({ method, path }) => {
31
36
  tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
32
- const captured = requestStore.findCapturedRequest(method, path);
37
+ let captured = requestStore.findCapturedRequest(method, path);
38
+ if (captured && !captured.requestBody && opts.spec && captured.status < 400)
39
+ captured = undefined;
33
40
  if (captured) {
34
41
  if (captured.status >= 400) {
35
42
  const rejectedCapture = {
@@ -80,7 +87,7 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
80
87
  Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
81
88
  `,
82
89
  inputSchema: z.object({
83
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
90
+ method: z.enum(allowedMethods).describe('HTTP method'),
84
91
  path: z.string().describe('API path (e.g., /suites, /suites/1)'),
85
92
  body: z.any().optional().describe('Request body (JSON object)'),
86
93
  queryParams: z.record(z.string(), z.string()).optional().describe('Query parameters'),
@@ -111,11 +118,14 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
111
118
  }
112
119
  const extracted = extractKeyFields(reqResult.responseBody);
113
120
  tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
114
- return {
121
+ const output = {
115
122
  success: true,
116
123
  status: reqResult.status,
117
124
  extracted,
118
125
  };
126
+ if (readOnly)
127
+ output.bodyPreview = reqResult.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT);
128
+ return output;
119
129
  },
120
130
  }),
121
131
  finish: tool({
@@ -160,8 +170,55 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
160
170
  },
161
171
  }),
162
172
  };
173
+ if (readOnly) {
174
+ tools.finish = tool({
175
+ description: 'Report the answer to the question. Call when the requests have shown what exists.',
176
+ inputSchema: z.object({
177
+ answer: z.string().describe('What the data shows, quoting the concrete names, titles and ids that were returned'),
178
+ }),
179
+ execute: async ({ answer }) => {
180
+ if (haul.successfulReads().length === 0) {
181
+ tag('warning').log('Fisherman: finish rejected — no successful request in this run');
182
+ 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.' };
183
+ }
184
+ tag('success').log(`Fisherman answered: ${answer}`);
185
+ finished = true;
186
+ result = { success: true, summary: answer, created: [], failed: [] };
187
+ return { finished: true };
188
+ },
189
+ });
190
+ }
163
191
  return { tools, getResult, isFinished, finishFromText };
164
192
  }
193
+ export function createAskApiTool(fisherman, task) {
194
+ return {
195
+ askApi: tool({
196
+ description: dedent `
197
+ Ask what data already exists, changing nothing.
198
+ Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
199
+ 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.
200
+ It never creates, edits or deletes anything — precondition() does that.
201
+ `,
202
+ inputSchema: z.object({
203
+ question: z.string().describe('What to find out about data that already exists'),
204
+ }),
205
+ execute: async ({ question }) => {
206
+ tag('info').log(`Ask API: ${question}`);
207
+ if (!fisherman?.isAvailable()) {
208
+ return { answered: false, reason: 'No API access is configured, so existing data cannot be queried. Judge from the page instead.' };
209
+ }
210
+ const result = await fisherman.lookupData(question, task.startUrl, task.sessionName);
211
+ if (!result.success) {
212
+ tag('warning').log(`Ask API unanswered: ${result.summary}`);
213
+ return { answered: false, reason: result.summary || 'The API could not answer this question' };
214
+ }
215
+ task.addNote(`Asked API: ${question} — ${result.summary}`);
216
+ tag('success').log(`Ask API: ${result.summary}`);
217
+ return { answered: true, answer: result.summary };
218
+ },
219
+ }),
220
+ };
221
+ }
165
222
  export function verifyFinish(haul, input) {
166
223
  const writes = haul.successfulWrites();
167
224
  if (writes.length === 0) {
@@ -186,15 +243,23 @@ export function verifyFinish(haul, input) {
186
243
  verified.push(...writes.map(toCreatedItem));
187
244
  return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
188
245
  }
189
- function synthesizeResult(haul, declaredDone) {
246
+ function synthesizeResult(haul, declaredDone, readOnly) {
190
247
  const made = haul.requests();
191
- const writes = haul.successfulWrites();
192
248
  const failures = haul.failed();
193
- let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
249
+ let succeeded = haul.successfulWrites();
250
+ let successLabel = 'successful writes';
251
+ if (readOnly) {
252
+ succeeded = haul.successfulReads();
253
+ successLabel = 'successful reads';
254
+ }
255
+ let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
194
256
  const lastFailure = failures[failures.length - 1];
195
257
  if (lastFailure)
196
258
  summary += `; last failure: ${lastFailure.toSummary()}`;
197
- return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
259
+ const result = { success: declaredDone && succeeded.length > 0, summary, created: [], failed: [] };
260
+ if (!readOnly)
261
+ result.created = succeeded.map(toCreatedItem);
262
+ return result;
198
263
  }
199
264
  function toCreatedItem(write) {
200
265
  const { id, title } = write.extractIdAndTitle();
@@ -1,8 +1,9 @@
1
1
  import type { ApiClient } from '../api/api-client.js';
2
- import { type RequestStore } from '../api/request-store.js';
2
+ import { type EndpointFamily, type RequestStore } from '../api/request-store.js';
3
3
  import type { Agent } from './agent.js';
4
- import { type FishermanResult } from './fisherman-tools.js';
4
+ import type { Conversation } from './conversation.js';
5
5
  import { RequestHaul } from './fisherman/request-haul.js';
6
+ import { type FishermanResult } from './fisherman/tools.js';
6
7
  import type { Provider } from './provider.js';
7
8
  export declare class Fisherman implements Agent {
8
9
  emoji: string;
@@ -23,10 +24,18 @@ export declare class Fisherman implements Agent {
23
24
  ensureReady(scopeUrl?: string): Promise<void>;
24
25
  getEndpointList(scopeUrl?: string): string;
25
26
  prepareData(instructions: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult>;
27
+ lookupData(question: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult>;
28
+ runSession(conversation: Conversation, tools: Record<string, any>, opts: {
29
+ haul: RequestHaul;
30
+ isFinished: () => boolean;
31
+ finishFromText: (text?: string) => void;
32
+ label: string;
33
+ }): Promise<void>;
26
34
  detectMode(scopeUrl?: string): Promise<void>;
27
35
  refreshAuth(): Promise<void>;
28
- buildEndpointList(scopeUrl?: string): string;
36
+ buildEndpointList(scopeUrl?: string, family?: EndpointFamily): string;
29
37
  buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
38
+ buildLookupSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
30
39
  isStuckOnEndpoint(haul: RequestHaul): boolean;
31
40
  buildTaskPrompt(instructions: string): string;
32
41
  }