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.
- package/bin/explorbot-cli.ts +6 -1
- package/boat/api-tester/src/apibot.ts +8 -13
- package/boat/api-tester/src/cli.ts +7 -3
- package/boat/api-tester/src/config.ts +45 -9
- package/boat/prima/src/cli.ts +33 -99
- package/boat/prima/src/envelope.ts +3 -1
- package/boat/prima/src/help.ts +72 -0
- package/boat/prima/src/prima.ts +33 -43
- package/dist/bin/explorbot-cli.js +5 -1
- package/dist/boat/api-tester/src/apibot.js +7 -6
- package/dist/boat/api-tester/src/cli.js +9 -3
- package/dist/boat/api-tester/src/config.js +32 -6
- package/dist/boat/prima/src/cli.js +30 -86
- package/dist/boat/prima/src/envelope.js +2 -1
- package/dist/boat/prima/src/help.js +63 -0
- package/dist/boat/prima/src/prima.js +29 -41
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +5 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/fisherman/request-haul.d.ts +1 -0
- package/dist/src/ai/fisherman/request-haul.js +3 -0
- package/dist/src/ai/fisherman/tools.d.ts +50 -0
- package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +78 -13
- package/dist/src/ai/fisherman.d.ts +12 -3
- package/dist/src/ai/fisherman.js +89 -13
- package/dist/src/ai/pilot.d.ts +13 -1
- package/dist/src/ai/pilot.js +20 -7
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +4 -1
- package/dist/src/ai/researcher/sections.d.ts +1 -1
- package/dist/src/ai/researcher/sections.js +2 -1
- package/dist/src/ai/researcher.js +25 -11
- package/dist/src/ai/rules.js +2 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +5 -0
- package/dist/src/api/request-result.js +3 -1
- package/dist/src/api/request-store.d.ts +6 -1
- package/dist/src/api/request-store.js +55 -17
- package/dist/src/api/xhr-capture.d.ts +2 -0
- package/dist/src/api/xhr-capture.js +35 -10
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/commands/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +14 -0
- package/dist/src/state-manager.js +5 -1
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +2 -0
- package/docs/reference/configuration.md +4 -0
- package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
- package/docs/workflow/agentic-usage.md +15 -1
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/fisherman/request-haul.ts +4 -0
- package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +93 -20
- package/src/ai/fisherman.ts +104 -15
- package/src/ai/pilot.ts +20 -7
- package/src/ai/researcher/deep-analysis.ts +4 -2
- package/src/ai/researcher/sections.ts +2 -2
- package/src/ai/researcher.ts +28 -11
- package/src/ai/rules.ts +2 -0
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +6 -0
- package/src/api/request-result.ts +2 -1
- package/src/api/request-store.ts +58 -18
- package/src/api/xhr-capture.ts +39 -11
- package/src/commands/config-command.ts +4 -1
- package/src/commands/help-json-command.ts +74 -0
- package/src/config.ts +16 -0
- package/src/state-manager.ts +6 -1
- 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
|
|
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
|
|
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
|
|
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 =
|
|
827
|
+
const map = getPreviousResearch(result.baseHash);
|
|
828
|
+
let uiMap = '';
|
|
827
829
|
if (map) {
|
|
828
|
-
|
|
829
|
-
<page_ui_map
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1032
|
+
writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8');
|
|
1045
1033
|
}
|
|
1046
1034
|
async writeSnapshot(result) {
|
|
1047
|
-
|
|
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().
|
|
1039
|
+
requests: this.bot.requestStore().getCapturedRequests(),
|
|
1052
1040
|
});
|
|
1053
1041
|
}
|
|
1054
1042
|
statusHash() {
|
package/dist/package.json
CHANGED
|
@@ -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
|
}
|
package/dist/src/action.js
CHANGED
|
@@ -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
|
}
|
|
@@ -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 "
|
|
5
|
-
import { tag } from "
|
|
6
|
-
import { isDynamicSegment } from "
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|