explorbot 0.3.4 → 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 +15 -1
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +47 -4
- 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 +11 -1
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +48 -4
- 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');
|
|
@@ -1005,6 +1016,8 @@ export class Pilot {
|
|
|
1005
1016
|
state), instruct Tester to verify() and finish(). If goal was already true at the start, propose
|
|
1006
1017
|
different input data so the test is meaningful. If Tester repeats the same successful action, STOP.
|
|
1007
1018
|
|
|
1019
|
+
If needed you should pick the exact item the scenario should act on (from the page, or precondition() one) and pass it to tester
|
|
1020
|
+
|
|
1008
1021
|
Action classification: GOAL-ADVANCING actions mutate the scenario's subject data (create/edit/delete/submit/verify).
|
|
1009
1022
|
VIEW-ONLY actions toggle filters/tabs/sort/collapse without changing data. One VIEW-ONLY to reveal a
|
|
1010
1023
|
target is fine; ≥2 consecutive VIEW-ONLY actions with no GOAL-ADVANCING action in between is thrashing
|
|
@@ -1022,6 +1035,7 @@ export class Pilot {
|
|
|
1022
1035
|
Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
|
|
1023
1036
|
- Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
|
|
1024
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.
|
|
1025
1039
|
- Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
|
|
1026
1040
|
- MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
|
|
1027
1041
|
- Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -24,7 +24,7 @@ const TasksSchema = z.object({
|
|
|
24
24
|
planName: z.string().describe('Short descriptive name for the test plan (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")'),
|
|
25
25
|
scenarios: z
|
|
26
26
|
.array(z.object({
|
|
27
|
-
scenario: z.string().describe('A single sentence describing
|
|
27
|
+
scenario: z.string().describe('A single sentence describing the behavior to test.'),
|
|
28
28
|
priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
|
|
29
29
|
startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
|
|
30
30
|
steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -2,7 +2,8 @@ import { OpenTelemetry } from '@ai-sdk/otel';
|
|
|
2
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
4
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
-
import
|
|
5
|
+
import dedent from 'dedent';
|
|
6
|
+
import { APICallError, generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
|
|
6
7
|
import { z } from 'zod';
|
|
7
8
|
import { clearActivity, setActivity } from "../activity.js";
|
|
8
9
|
import { configuredModels, modelName as getModelName } from '../config.js';
|
|
@@ -382,9 +383,28 @@ export class Provider {
|
|
|
382
383
|
if (extraStop)
|
|
383
384
|
stopConditions.push(extraStop);
|
|
384
385
|
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
386
|
+
let attemptMessages = messages;
|
|
387
|
+
let invalidRequestFeedbackAdded = false;
|
|
388
|
+
const executedStepMessages = [];
|
|
385
389
|
try {
|
|
386
390
|
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
387
|
-
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
|
+
}
|
|
401
|
+
if (!invalidRequestFeedbackAdded) {
|
|
402
|
+
const amended = withInvalidRequestFeedback(attemptMessages, error);
|
|
403
|
+
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
404
|
+
attemptMessages = amended;
|
|
405
|
+
}
|
|
406
|
+
throw error;
|
|
407
|
+
}));
|
|
388
408
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
389
409
|
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
390
410
|
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
@@ -393,6 +413,7 @@ export class Provider {
|
|
|
393
413
|
return result;
|
|
394
414
|
}, this.getRetryOptions(options)));
|
|
395
415
|
clearActivity();
|
|
416
|
+
withExecutedSteps(response, executedStepMessages);
|
|
396
417
|
// Log tool usage summary
|
|
397
418
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
398
419
|
responseLog(response.toolCalls);
|
|
@@ -406,14 +427,15 @@ export class Provider {
|
|
|
406
427
|
catch (error) {
|
|
407
428
|
clearActivity();
|
|
408
429
|
if (error?.message?.includes('Tool choice is required')) {
|
|
409
|
-
return { text: '', toolCalls: [], toolResults: [],
|
|
430
|
+
return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
|
|
410
431
|
}
|
|
411
432
|
if (error?.name === 'AbortError')
|
|
412
433
|
throw error;
|
|
413
434
|
if (error instanceof ContextLengthError)
|
|
414
435
|
throw error;
|
|
415
436
|
if (Provider.isContextLengthError(error)) {
|
|
416
|
-
|
|
437
|
+
const recovered = await this.recoverFromContextLength(error, attemptMessages, options, (m, o) => this.generateWithTools(m, model, tools, o));
|
|
438
|
+
return withExecutedSteps(recovered, executedStepMessages);
|
|
417
439
|
}
|
|
418
440
|
if (error.constructor?.name === 'AI_APICallError') {
|
|
419
441
|
responseLog(error.message);
|
|
@@ -620,6 +642,27 @@ function repairToolCall(options) {
|
|
|
620
642
|
return repairChannelMarker(options);
|
|
621
643
|
return repairHarmonyChannel(options);
|
|
622
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
|
+
}
|
|
650
|
+
function withInvalidRequestFeedback(messages, error) {
|
|
651
|
+
if (!(error instanceof APICallError) || error.statusCode !== 400)
|
|
652
|
+
return messages;
|
|
653
|
+
tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
|
|
654
|
+
return [
|
|
655
|
+
...messages,
|
|
656
|
+
{
|
|
657
|
+
role: 'user',
|
|
658
|
+
content: dedent `
|
|
659
|
+
The previous request was rejected by the provider as invalid:
|
|
660
|
+
"${error.message}"
|
|
661
|
+
Fix what it describes and re-issue the request.
|
|
662
|
+
`,
|
|
663
|
+
},
|
|
664
|
+
];
|
|
665
|
+
}
|
|
623
666
|
function repairChannelMarker({ toolCall, tools }) {
|
|
624
667
|
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
625
668
|
if (markerIndex <= 0)
|
|
@@ -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 {};
|