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