@kkeve/suite 0.0.12 → 0.0.13
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/dist/core/keve-test.js +5 -3
- package/dist/core/learned-actions.d.ts +37 -8
- package/dist/core/learned-actions.js +214 -22
- package/dist/page-agent/agent.d.ts +5 -0
- package/dist/page-agent/agent.js +192 -4
- package/dist/page-agent/system_prompt.md +70 -14
- package/dist/page-agent/tools.js +517 -16
- package/package.json +1 -1
package/dist/core/keve-test.js
CHANGED
|
@@ -87,9 +87,10 @@ keve_aspect_1.keveAspect.register({
|
|
|
87
87
|
phase: 'after',
|
|
88
88
|
order: 20,
|
|
89
89
|
async execute(ctx, result) {
|
|
90
|
-
if (!result?.actions?.length
|
|
90
|
+
if (!result?.actions?.length)
|
|
91
91
|
return;
|
|
92
|
-
|
|
92
|
+
const url = ctx.page.url();
|
|
93
|
+
learned_actions_1.learnedActions.add(ctx.step, result.actions, url, result.success);
|
|
93
94
|
},
|
|
94
95
|
});
|
|
95
96
|
// ─── (diagnostic extraction moved to page-agent/diagnostic.ts, called via onAfterTask hook) ──
|
|
@@ -202,7 +203,8 @@ exports.test = test_1.test.extend({
|
|
|
202
203
|
catch { /* non-critical */ }
|
|
203
204
|
}
|
|
204
205
|
// ── Hand off to agent: agent does Re-Act (OR shortcut to blocked if fnBlocked) ──
|
|
205
|
-
const
|
|
206
|
+
const currentUrl = page.url();
|
|
207
|
+
const learnedHint = learned_actions_1.learnedActions.getHint(options.step, currentUrl);
|
|
206
208
|
let reactResult;
|
|
207
209
|
let reactTimedOut = false;
|
|
208
210
|
if (fnBlocked) {
|
|
@@ -4,21 +4,50 @@
|
|
|
4
4
|
* When one keveGoal's Re-Act loop discovers a working action sequence,
|
|
5
5
|
* other goals in the same round can use those discoveries as hints.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
|
|
8
|
+
* Exact match is tried first; if no hit, fuzzy search across all entries.
|
|
9
9
|
*/
|
|
10
|
+
interface CacheEntry {
|
|
11
|
+
step: string;
|
|
12
|
+
actions: any[];
|
|
13
|
+
url?: string;
|
|
14
|
+
success: boolean;
|
|
15
|
+
}
|
|
10
16
|
export declare class LearnedActions {
|
|
11
17
|
private cache;
|
|
12
|
-
/** Store discovered actions for a step
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
/** Store discovered actions for a step (with optional page URL for context matching)
|
|
19
|
+
* @param success Whether the step succeeded — failed entries are stored with warnings only
|
|
20
|
+
*/
|
|
21
|
+
add(step: string, actions: any[], url?: string, success?: boolean): void;
|
|
22
|
+
/** Retrieve discovered actions for a step (exact match) */
|
|
15
23
|
get(step: string): any[] | undefined;
|
|
16
|
-
/**
|
|
17
|
-
|
|
24
|
+
/** Tokenize a Chinese/mixed step description for fuzzy matching.
|
|
25
|
+
* Uses bigram (character pair) extraction for Chinese — more granular than
|
|
26
|
+
* whitespace splitting, captures shared sub-phrases like "指标标准名称".
|
|
27
|
+
*/
|
|
28
|
+
private tokenize;
|
|
29
|
+
/** Compute Jaccard similarity between two token sets */
|
|
30
|
+
private jaccard;
|
|
31
|
+
/** Find the best matching cached entry for a given step description */
|
|
32
|
+
private findBestMatch;
|
|
33
|
+
/** Get a hint string for injecting into Re-Act prompt.
|
|
34
|
+
* 1. Exact match → direct reuse
|
|
35
|
+
* 2. Fuzzy match by token similarity → similar step reuse
|
|
36
|
+
* 3. Same-page fallback → if same URL path, use the most recent successful entry
|
|
37
|
+
*/
|
|
38
|
+
getHint(step: string, currentUrl?: string): string | undefined;
|
|
39
|
+
/** Extract path portion of URL for same-page matching (ignore query/hash) */
|
|
40
|
+
private extractPath;
|
|
41
|
+
/** Format a hint from a cache entry's actions.
|
|
42
|
+
* Success: action sequence summary + key discoveries
|
|
43
|
+
* Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
|
|
44
|
+
*/
|
|
45
|
+
private formatHint;
|
|
18
46
|
/** Get all cached entries */
|
|
19
|
-
getAll(): Map<string,
|
|
47
|
+
getAll(): Map<string, CacheEntry>;
|
|
20
48
|
/** Clear all cached entries (typically at round start) */
|
|
21
49
|
clear(): void;
|
|
22
50
|
}
|
|
23
51
|
/** Global singleton for same-round sharing */
|
|
24
52
|
export declare const learnedActions: LearnedActions;
|
|
53
|
+
export {};
|
|
@@ -5,51 +5,243 @@
|
|
|
5
5
|
* When one keveGoal's Re-Act loop discovers a working action sequence,
|
|
6
6
|
* other goals in the same round can use those discoveries as hints.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
|
|
9
|
+
* Exact match is tried first; if no hit, fuzzy search across all entries.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.learnedActions = exports.LearnedActions = void 0;
|
|
13
13
|
class LearnedActions {
|
|
14
14
|
cache = new Map();
|
|
15
|
-
/** Store discovered actions for a step
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/** Store discovered actions for a step (with optional page URL for context matching)
|
|
16
|
+
* @param success Whether the step succeeded — failed entries are stored with warnings only
|
|
17
|
+
*/
|
|
18
|
+
add(step, actions, url, success = true) {
|
|
18
19
|
if (actions.length > 0) {
|
|
19
|
-
this.cache.set(step, actions);
|
|
20
|
+
this.cache.set(step, { step, actions, url, success });
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
|
-
/** Retrieve discovered actions for a step */
|
|
23
|
+
/** Retrieve discovered actions for a step (exact match) */
|
|
23
24
|
get(step) {
|
|
24
|
-
return this.cache.get(step);
|
|
25
|
+
return this.cache.get(step)?.actions;
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
27
|
+
/** Tokenize a Chinese/mixed step description for fuzzy matching.
|
|
28
|
+
* Uses bigram (character pair) extraction for Chinese — more granular than
|
|
29
|
+
* whitespace splitting, captures shared sub-phrases like "指标标准名称".
|
|
30
|
+
*/
|
|
31
|
+
tokenize(text) {
|
|
32
|
+
// 1. Split by delimiters to get chunks
|
|
33
|
+
const chunks = text
|
|
34
|
+
.replace(/[,。、;:""''()【】《》\s,.;:!?/\\|]+/g, ' ')
|
|
35
|
+
.split(' ')
|
|
36
|
+
.filter(t => t.length > 0);
|
|
37
|
+
// 2. For each chunk, extract character bigrams (for Chinese sub-phrase matching)
|
|
38
|
+
// AND keep the original chunk (for exact chunk matches)
|
|
39
|
+
const tokens = [];
|
|
40
|
+
for (const chunk of chunks) {
|
|
41
|
+
tokens.push(chunk.toLowerCase());
|
|
42
|
+
// Generate bigrams: sliding window of 2 chars
|
|
43
|
+
for (let i = 0; i < chunk.length - 1; i++) {
|
|
44
|
+
tokens.push(chunk.substring(i, i + 2).toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return tokens;
|
|
48
|
+
}
|
|
49
|
+
/** Compute Jaccard similarity between two token sets */
|
|
50
|
+
jaccard(a, b) {
|
|
51
|
+
const setA = new Set(a.map(t => t.toLowerCase()));
|
|
52
|
+
const setB = new Set(b.map(t => t.toLowerCase()));
|
|
53
|
+
const intersection = [...setA].filter(t => setB.has(t)).length;
|
|
54
|
+
const union = new Set([...setA, ...setB]).size;
|
|
55
|
+
return union === 0 ? 0 : intersection / union;
|
|
56
|
+
}
|
|
57
|
+
/** Find the best matching cached entry for a given step description */
|
|
58
|
+
findBestMatch(step) {
|
|
59
|
+
const tokens = this.tokenize(step);
|
|
60
|
+
let bestEntry;
|
|
61
|
+
let bestScore = 0;
|
|
62
|
+
// Threshold: 18% token overlap — low enough to catch "填写其他必填项" vs "必填项填入合法值",
|
|
63
|
+
// high enough to avoid completely unrelated steps matching
|
|
64
|
+
const THRESHOLD = 0.18;
|
|
65
|
+
for (const [, entry] of this.cache) {
|
|
66
|
+
const entryTokens = this.tokenize(entry.step);
|
|
67
|
+
const score = this.jaccard(tokens, entryTokens);
|
|
68
|
+
if (score > bestScore && score >= THRESHOLD) {
|
|
69
|
+
bestScore = score;
|
|
70
|
+
bestEntry = entry;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return bestEntry;
|
|
74
|
+
}
|
|
75
|
+
/** Get a hint string for injecting into Re-Act prompt.
|
|
76
|
+
* 1. Exact match → direct reuse
|
|
77
|
+
* 2. Fuzzy match by token similarity → similar step reuse
|
|
78
|
+
* 3. Same-page fallback → if same URL path, use the most recent successful entry
|
|
79
|
+
*/
|
|
80
|
+
getHint(step, currentUrl) {
|
|
81
|
+
// 1. Try exact match first (prefer success entries over failures)
|
|
82
|
+
const exact = this.cache.get(step);
|
|
83
|
+
if (exact?.actions.length) {
|
|
84
|
+
const hint = this.formatHint(exact.step, exact.actions, false, exact.success);
|
|
85
|
+
if (hint)
|
|
86
|
+
return hint;
|
|
87
|
+
}
|
|
88
|
+
// 2. Fuzzy match by step description similarity (prefer success, but include failures)
|
|
89
|
+
const best = this.findBestMatch(step);
|
|
90
|
+
if (best?.actions.length) {
|
|
91
|
+
const hint = this.formatHint(best.step, best.actions, true, best.success);
|
|
92
|
+
if (hint)
|
|
93
|
+
return hint;
|
|
94
|
+
}
|
|
95
|
+
// 3. Same-page fallback: if on the same URL path as a cached entry, use the most
|
|
96
|
+
// relevant one (highest Jaccard, even if below fuzzy threshold).
|
|
97
|
+
// Minimum Jaccard = 0.08 to avoid completely unrelated steps on the same page.
|
|
98
|
+
if (currentUrl) {
|
|
99
|
+
const urlPath = this.extractPath(currentUrl);
|
|
100
|
+
let bestSamePage;
|
|
101
|
+
let bestSamePageScore = 0;
|
|
102
|
+
const SAME_PAGE_MIN = 0.08;
|
|
103
|
+
const stepTokens = this.tokenize(step);
|
|
104
|
+
for (const [, entry] of this.cache) {
|
|
105
|
+
if (entry.url && this.extractPath(entry.url) === urlPath) {
|
|
106
|
+
const entryTokens = this.tokenize(entry.step);
|
|
107
|
+
const score = this.jaccard(stepTokens, entryTokens);
|
|
108
|
+
if (score > bestSamePageScore && score >= SAME_PAGE_MIN) {
|
|
109
|
+
bestSamePageScore = score;
|
|
110
|
+
bestSamePage = entry;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (bestSamePage?.actions.length) {
|
|
115
|
+
const hint = this.formatHint(bestSamePage.step, bestSamePage.actions, true, bestSamePage.success);
|
|
116
|
+
if (hint)
|
|
117
|
+
return hint;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
/** Extract path portion of URL for same-page matching (ignore query/hash) */
|
|
123
|
+
extractPath(url) {
|
|
124
|
+
try {
|
|
125
|
+
const u = new URL(url);
|
|
126
|
+
return u.pathname;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return url;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Format a hint from a cache entry's actions.
|
|
133
|
+
* Success: action sequence summary + key discoveries
|
|
134
|
+
* Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
|
|
135
|
+
*/
|
|
136
|
+
formatHint(sourceStep, actions, isFuzzy, success) {
|
|
137
|
+
if (!success) {
|
|
138
|
+
// Failed step — surface warnings/blockers AND combobox conflict info
|
|
139
|
+
const discoveries = [];
|
|
140
|
+
for (const a of actions) {
|
|
141
|
+
const out = a.toolOutput || '';
|
|
142
|
+
if (out.startsWith('⚠️'))
|
|
143
|
+
discoveries.push(out.slice(0, 120));
|
|
144
|
+
// Also capture combobox "重复" conflicts — valuable for later goals to avoid same data
|
|
145
|
+
if (out.includes('重复') || out.includes('duplicate') || out.includes('conflict')) {
|
|
146
|
+
discoveries.push(`DATA CONFLICT: ${out.slice(0, 120)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (discoveries.length === 0)
|
|
150
|
+
return undefined;
|
|
151
|
+
const prefix = isFuzzy
|
|
152
|
+
? `Previous FAILED step SIMILAR to "${sourceStep}"`
|
|
153
|
+
: `Previous FAILED step "${sourceStep}"`;
|
|
154
|
+
return `${prefix} — avoid these approaches. Blocked by: ${discoveries.slice(-3).map(d => `[${d}]`).join('; ')}`;
|
|
155
|
+
}
|
|
156
|
+
// Success — full action sequence + discoveries (existing logic)
|
|
157
|
+
// Extract stable element identity from toolOutput (not from action fields which lack role/name)
|
|
32
158
|
const summary = actions
|
|
33
|
-
.filter(a => a.action.tool !== 'done')
|
|
159
|
+
.filter(a => a.action?.tool !== 'done' && a.tool !== 'done')
|
|
34
160
|
.map(a => {
|
|
35
|
-
const act = a.action;
|
|
161
|
+
const act = a.action || a;
|
|
162
|
+
const out = a.toolOutput || '';
|
|
36
163
|
if (act.tool === 'navigate')
|
|
37
164
|
return `navigate(${act.url})`;
|
|
38
|
-
if (act.tool === 'click')
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
165
|
+
if (act.tool === 'click') {
|
|
166
|
+
// Parse toolOutput: "✅ Clicked [ref=e688] button \"新增指标\"" → "button 新增指标"
|
|
167
|
+
// or: "⚠️ Clicked [ref=e2843] button \"确定\" — dialog still open" → "button 确定"
|
|
168
|
+
const descMatch = out.match(/Clicked \[ref=\w+\]\s+(\S+\s+"[^"]*"|\S+)/);
|
|
169
|
+
if (descMatch) {
|
|
170
|
+
// descMatch[1] = 'button "新增指标"' or 'button "确定"' or 'element'
|
|
171
|
+
const clean = descMatch[1].replace(/"/g, '').trim();
|
|
172
|
+
return `click(${clean})`;
|
|
173
|
+
}
|
|
174
|
+
return `click(?)`;
|
|
175
|
+
}
|
|
176
|
+
if (act.tool === 'type') {
|
|
177
|
+
// Parse toolOutput for stable type info
|
|
178
|
+
const typeMatch = out.match(/✅ (replace|append) "([^"]*)" into element \[ref=\w+\]/);
|
|
179
|
+
if (typeMatch)
|
|
180
|
+
return `type("${typeMatch[2].slice(0, 30)}"${typeMatch[1] === 'append' ? ', append' : ''})`;
|
|
181
|
+
// Combobox selection
|
|
182
|
+
const cbMatch = out.match(/✅ Selected "([^"]*)" in combobox/);
|
|
183
|
+
if (cbMatch)
|
|
184
|
+
return `select("${cbMatch[1].slice(0, 30)}")`;
|
|
185
|
+
return `type(?)`;
|
|
186
|
+
}
|
|
187
|
+
if (act.tool === 'fill_form') {
|
|
188
|
+
// Extract filled fields from multi-line toolOutput
|
|
189
|
+
// Lines look like: "✅ textbox [e2755] = "测试指标标准名称"" or "✅ combobox [e2800] = "KwaiBI官方数据集DEMO" (custom)"
|
|
190
|
+
const fields = [];
|
|
191
|
+
for (const line of out.split('\n')) {
|
|
192
|
+
const fieldMatch = line.match(/✅ (\w+) \[\w+\] = "?([^"]*?)"?\s*(\(custom\))?$/);
|
|
193
|
+
if (fieldMatch && fieldMatch[2]) {
|
|
194
|
+
const fType = fieldMatch[1];
|
|
195
|
+
const fVal = fieldMatch[2].slice(0, 25);
|
|
196
|
+
fields.push(`${fType}="${fVal}"`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return fields.length ? `fill_form(${fields.join(', ')})` : 'fill_form(...)';
|
|
200
|
+
}
|
|
201
|
+
if (act.tool === 'select_option') {
|
|
202
|
+
const selMatch = out.match(/✅ Selected "([^"]*)"/);
|
|
203
|
+
if (selMatch)
|
|
204
|
+
return `select_option("${selMatch[1].slice(0, 30)}")`;
|
|
205
|
+
return `select_option(?)`;
|
|
206
|
+
}
|
|
42
207
|
if (act.tool === 'hover')
|
|
43
|
-
return `hover(${act.role} "${act.name}")`;
|
|
208
|
+
return `hover(${act.role || ''} "${act.name || ''}")`;
|
|
44
209
|
if (act.tool === 'pressKey')
|
|
45
210
|
return `pressKey(${act.key})`;
|
|
46
211
|
if (act.tool === 'scroll')
|
|
47
212
|
return `scroll(${act.direction})`;
|
|
48
213
|
if (act.tool === 'wait')
|
|
49
214
|
return `wait(${act.time}ms)`;
|
|
215
|
+
if (act.tool === 'execute_javascript') {
|
|
216
|
+
const jsResult = out.match(/✅ JS executed\. Result: (.+)/);
|
|
217
|
+
if (jsResult)
|
|
218
|
+
return `js(→ ${jsResult[1].slice(0, 40)})`;
|
|
219
|
+
return 'js(?)';
|
|
220
|
+
}
|
|
50
221
|
return act.tool;
|
|
51
222
|
});
|
|
52
|
-
|
|
223
|
+
// Extract key toolOutput lines — discoveries that other goals need to know
|
|
224
|
+
// Focus on: combobox option results (✅/❌), dialog warnings (⚠️), validation messages
|
|
225
|
+
const discoveries = [];
|
|
226
|
+
for (const a of actions) {
|
|
227
|
+
const out = a.toolOutput || '';
|
|
228
|
+
// Combobox option selection results (most valuable for form reuse)
|
|
229
|
+
if (out.includes('combobox') && (out.includes('(custom)') || out.includes('could not find option'))) {
|
|
230
|
+
discoveries.push(out.slice(0, 120));
|
|
231
|
+
}
|
|
232
|
+
// Dialog warnings that blocked submission
|
|
233
|
+
if (out.startsWith('⚠️')) {
|
|
234
|
+
discoveries.push(out.slice(0, 120));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Limit to 5 most important discoveries
|
|
238
|
+
const discoveryLines = discoveries.length > 0
|
|
239
|
+
? `\nKey discoveries: ${discoveries.slice(-5).map(d => `[${d}]`).join('; ')}`
|
|
240
|
+
: '';
|
|
241
|
+
const prefix = isFuzzy
|
|
242
|
+
? `Previous successful actions for SIMILAR step "${sourceStep}"`
|
|
243
|
+
: `Previous successful actions for "${sourceStep}"`;
|
|
244
|
+
return `${prefix}: ${summary.join(' → ')}${discoveryLines}`;
|
|
53
245
|
}
|
|
54
246
|
/** Get all cached entries */
|
|
55
247
|
getAll() {
|
|
@@ -110,6 +110,11 @@ export declare class KevePageAgent {
|
|
|
110
110
|
bbox?: number[];
|
|
111
111
|
analysis?: string;
|
|
112
112
|
}>;
|
|
113
|
+
/** Visual assertion: check a visual condition without interacting with the page */
|
|
114
|
+
visualAssertElement(screenshotBase64: string, assertion: string): Promise<{
|
|
115
|
+
passed: boolean;
|
|
116
|
+
reasoning: string;
|
|
117
|
+
}>;
|
|
113
118
|
/** Build final result */
|
|
114
119
|
private buildResult;
|
|
115
120
|
}
|
package/dist/page-agent/agent.js
CHANGED
|
@@ -61,6 +61,91 @@ const tools_1 = require("./tools");
|
|
|
61
61
|
const config_1 = require("../config");
|
|
62
62
|
// ─── System Prompt ────────────────────────────────────────────────────
|
|
63
63
|
const SYSTEM_PROMPT = fs.readFileSync(path.join(__dirname, "system_prompt.md"), "utf-8");
|
|
64
|
+
// ─── Snapshot diff helpers (used by assembleUserPrompt to add Page Change to history) ───
|
|
65
|
+
/** Find the snapshot string of the previous step event before index `i`. */
|
|
66
|
+
function findPrevStepSnapshot(events, currentIdx) {
|
|
67
|
+
for (let j = currentIdx - 1; j >= 0; j--) {
|
|
68
|
+
if (events[j].type === 'step' && events[j].snapshot)
|
|
69
|
+
return events[j].snapshot;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Summarize key differences between two a11y snapshots.
|
|
75
|
+
* Returns a short human-readable string or '' if nothing notable changed.
|
|
76
|
+
*
|
|
77
|
+
* Detects: dialog/modal appearance/disappearance, new/removed elements,
|
|
78
|
+
* value changes in inputs, and repeated identical snapshots (no change).
|
|
79
|
+
*/
|
|
80
|
+
function summarizeSnapshotDiff(prev, curr) {
|
|
81
|
+
if (prev === curr)
|
|
82
|
+
return 'no visible change on page';
|
|
83
|
+
const prevLines = prev.split('\n');
|
|
84
|
+
const currLines = curr.split('\n');
|
|
85
|
+
const changes = [];
|
|
86
|
+
// 1. Detect dialog/modal appearance or disappearance
|
|
87
|
+
const prevDialogs = prevLines.filter(l => /dialog|modal|alertdialog/i.test(l) && /\[ref=/.test(l));
|
|
88
|
+
const currDialogs = currLines.filter(l => /dialog|modal|alertdialog/i.test(l) && /\[ref=/.test(l));
|
|
89
|
+
if (currDialogs.length > prevDialogs.length) {
|
|
90
|
+
const newDialog = currDialogs.find(cd => !prevDialogs.some(pd => pd === cd));
|
|
91
|
+
if (newDialog) {
|
|
92
|
+
const nameMatch = newDialog.match(/"([^"]+)"/);
|
|
93
|
+
changes.push(`dialog "${nameMatch?.[1] || '?'}" appeared`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (currDialogs.length < prevDialogs.length) {
|
|
97
|
+
changes.push('dialog closed');
|
|
98
|
+
}
|
|
99
|
+
// 2. Detect new warning/error text nodes (e.g. validation errors)
|
|
100
|
+
const prevWarnings = prevLines.filter(l => /warning|error|alert|⚠|❌/i.test(l) && /\[ref=/.test(l));
|
|
101
|
+
const currWarnings = currLines.filter(l => /warning|error|alert|⚠|❌/i.test(l) && /\[ref=/.test(l));
|
|
102
|
+
for (const w of currWarnings) {
|
|
103
|
+
if (!prevWarnings.some(pw => pw === w)) {
|
|
104
|
+
const textMatch = w.match(/"([^"]+)"/);
|
|
105
|
+
changes.push(`new warning/error: "${textMatch?.[1] || '?'}"`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// 3. Count added/removed interactive elements (simplified: lines with [ref=])
|
|
109
|
+
const prevRefs = new Set(prevLines.filter(l => /\[ref=/.test(l)).map(l => {
|
|
110
|
+
const m = l.match(/\[ref=([a-f0-9]+)\]/);
|
|
111
|
+
return m?.[1] || '';
|
|
112
|
+
}));
|
|
113
|
+
const currRefs = new Set(currLines.filter(l => /\[ref=/.test(l)).map(l => {
|
|
114
|
+
const m = l.match(/\[ref=([a-f0-9]+)\]/);
|
|
115
|
+
return m?.[1] || '';
|
|
116
|
+
}));
|
|
117
|
+
const added = [...currRefs].filter(r => r && !prevRefs.has(r));
|
|
118
|
+
const removed = [...prevRefs].filter(r => r && !currRefs.has(r));
|
|
119
|
+
if (added.length > 0)
|
|
120
|
+
changes.push(`${added.length} new element(s) appeared`);
|
|
121
|
+
if (removed.length > 0)
|
|
122
|
+
changes.push(`${removed.length} element(s) removed`);
|
|
123
|
+
// 4. Detect value changes in textboxes (content after colon on same line)
|
|
124
|
+
const prevValues = new Map();
|
|
125
|
+
for (const l of prevLines) {
|
|
126
|
+
const refM = l.match(/\[ref=([a-f0-9]+)\]/);
|
|
127
|
+
if (refM) {
|
|
128
|
+
// Value is text after the last colon on the same line
|
|
129
|
+
const valM = l.match(/: (.+)$/);
|
|
130
|
+
if (valM)
|
|
131
|
+
prevValues.set(refM[1], valM[1].trim());
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const l of currLines) {
|
|
135
|
+
const refM = l.match(/\[ref=([a-f0-9]+)\]/);
|
|
136
|
+
if (refM) {
|
|
137
|
+
const valM = l.match(/: (.+)$/);
|
|
138
|
+
if (valM && prevValues.has(refM[1])) {
|
|
139
|
+
const prevVal = prevValues.get(refM[1]);
|
|
140
|
+
const currVal = valM[1].trim();
|
|
141
|
+
if (prevVal !== currVal && currVal) {
|
|
142
|
+
changes.push(`[ref=${refM[1]}] value changed: "${prevVal}" → "${currVal}"`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return changes.length > 0 ? changes.join('; ') : 'page content changed';
|
|
148
|
+
}
|
|
64
149
|
// ─── KevePageAgent ──────────────────────────────────────────────────────
|
|
65
150
|
class KevePageAgent {
|
|
66
151
|
page;
|
|
@@ -115,7 +200,7 @@ class KevePageAgent {
|
|
|
115
200
|
this.pushObservation(`Target application URL: ${targetUrl}. Use this URL for navigation. Do NOT construct URLs yourself — use the navigate tool with this URL or read process.env.KEVE_TARGET_URL.`);
|
|
116
201
|
}
|
|
117
202
|
if (options?.learnedActionsHint) {
|
|
118
|
-
this.pushObservation(`Previous discoveries for similar steps:\n${options.learnedActionsHint}
|
|
203
|
+
this.pushObservation(`Previous discoveries for similar steps:\n${options.learnedActionsHint}\nUse these discoveries to avoid repeating failed approaches. If a previous step found a working way to fill a form field (especially combobox/dropdown), reuse that approach instead of trial-and-error.`);
|
|
119
204
|
}
|
|
120
205
|
// Step limit warning
|
|
121
206
|
if (maxSteps <= 3) {
|
|
@@ -209,10 +294,12 @@ class KevePageAgent {
|
|
|
209
294
|
if (actionName === 'execute_javascript' && execResult.output && execResult.output.startsWith('❌')) {
|
|
210
295
|
const jsRulesHint = [
|
|
211
296
|
'execute_javascript rules — fix your script:',
|
|
212
|
-
'1.
|
|
213
|
-
'2.
|
|
297
|
+
'1. Include `return` for multi-statement code. Single expressions are auto-wrapped.',
|
|
298
|
+
'2. const/let are auto-converted to var — no need to write var manually.',
|
|
214
299
|
'3. NO top-level `await` or `async () => {}` wrappers — they return undefined.',
|
|
215
300
|
'4. NO Playwright APIs (`page`, `locator`) — only browser DOM APIs.',
|
|
301
|
+
'5. FORM INTERACTION allowed: input.value=, dispatchEvent().',
|
|
302
|
+
'6. FORBIDDEN: style mutation (style.xxx=, style.setProperty), classList mutation, className=, innerHTML=.',
|
|
216
303
|
].join('\n');
|
|
217
304
|
this.pushObservation(jsRulesHint);
|
|
218
305
|
}
|
|
@@ -296,6 +383,53 @@ class KevePageAgent {
|
|
|
296
383
|
await hooks?.onAfterStep?.(this, this.events);
|
|
297
384
|
}
|
|
298
385
|
stepCount++;
|
|
386
|
+
// ── Stuck detection: break retry loops early ──
|
|
387
|
+
// When the last 3 steps produce identical toolOutput (no page progress),
|
|
388
|
+
// inject a forceful observation to break the loop.
|
|
389
|
+
// Also detects oscillation patterns (e.g. A→B→A→B cycling between two states).
|
|
390
|
+
const recentSteps = this.events
|
|
391
|
+
.filter((e) => e.type === 'step')
|
|
392
|
+
.slice(-4);
|
|
393
|
+
if (recentSteps.length >= 3) {
|
|
394
|
+
const last3 = recentSteps.slice(-3);
|
|
395
|
+
const normalize = (s) => (s || '').replace(/executed for \d+ms/, '').slice(0, 80);
|
|
396
|
+
const outputs = last3.map(s => normalize(s.toolOutput));
|
|
397
|
+
const allSame = outputs.every(o => o === outputs[0]);
|
|
398
|
+
if (allSame) {
|
|
399
|
+
const stuckMsg = [
|
|
400
|
+
`⚠️ STUCK DETECTED: The last 3 actions all produced the same result: "${outputs[0].slice(0, 60)}"`,
|
|
401
|
+
`This means the page is NOT responding to your actions. You MUST change strategy:`,
|
|
402
|
+
`- Try a different tool (e.g. select_option instead of fill_form, execute_javascript instead of click)`,
|
|
403
|
+
`- Try a different element or value`,
|
|
404
|
+
`- If a dialog blocks submission, handle the sub-dialog/popup first or dismiss it with Escape`,
|
|
405
|
+
`- If stuck after 2 more attempts: call done(verdict="fail") honestly`,
|
|
406
|
+
].join('\n');
|
|
407
|
+
this.pushObservation(stuckMsg);
|
|
408
|
+
}
|
|
409
|
+
// ── Oscillation detection: check if last 6 steps cycle between 2-3 states ──
|
|
410
|
+
// E.g. click确定→ownership popup → click返回→conflict warning → click确定→ownership popup
|
|
411
|
+
const recent6 = this.events
|
|
412
|
+
.filter((e) => e.type === 'step')
|
|
413
|
+
.slice(-6);
|
|
414
|
+
if (recent6.length >= 5 && !allSame) {
|
|
415
|
+
const norm6 = recent6.map(s => normalize(s.toolOutput));
|
|
416
|
+
// Extract the set of unique outputs (ignoring minor variations)
|
|
417
|
+
const uniqueOutputs = [...new Set(norm6)];
|
|
418
|
+
if (uniqueOutputs.length <= 2) {
|
|
419
|
+
// All recent steps cycle between ≤2 distinct outputs → oscillation
|
|
420
|
+
const oscMsg = [
|
|
421
|
+
`⚠️ OSCILLATION DETECTED: The last ${recent6.length} actions cycle between ${uniqueOutputs.length} states without progress:`,
|
|
422
|
+
...uniqueOutputs.map(o => ` - "${o.slice(0, 50)}"`),
|
|
423
|
+
`You are going back and forth without advancing. You MUST break the cycle:`,
|
|
424
|
+
`- If clicking 确定 triggers a repeated warning, change the FORM VALUES (not just the name) to resolve the warning`,
|
|
425
|
+
`- If "口径重复" blocks submission, change the DATASET or INDICATOR (not just the indicator name)`,
|
|
426
|
+
`- If a sub-dialog keeps appearing, dismiss it with Escape or handle it first`,
|
|
427
|
+
`- Call done(verdict="fail") honestly if the conflict cannot be resolved within current steps`,
|
|
428
|
+
].join('\n');
|
|
429
|
+
this.pushObservation(oscMsg);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
299
433
|
if (stepCount >= maxSteps) {
|
|
300
434
|
console.error(`\x1b[31mStep count exceeded maximum limit (${maxSteps})\x1b[0m`);
|
|
301
435
|
const finalSnapshot = await this.page.ariaSnapshot();
|
|
@@ -357,13 +491,21 @@ class KevePageAgent {
|
|
|
357
491
|
prompt += '</agent_state>\n\n';
|
|
358
492
|
// <agent_history>
|
|
359
493
|
prompt += '<agent_history>\n';
|
|
360
|
-
for (
|
|
494
|
+
for (let i = 0; i < this.events.length; i++) {
|
|
495
|
+
const event = this.events[i];
|
|
361
496
|
if (event.type === 'step') {
|
|
362
497
|
prompt += `<step_${event.stepIndex + 1}>\n`;
|
|
363
498
|
prompt += `Evaluation of Previous Step: ${event.evaluation}\n`;
|
|
364
499
|
prompt += `Memory: ${event.memory}\n`;
|
|
365
500
|
prompt += `Next Goal: ${event.nextGoal}\n`;
|
|
366
501
|
prompt += `Action: ${event.toolName} → ${event.toolOutput || event.toolError || 'unknown'}\n`;
|
|
502
|
+
// Add snapshot diff summary: compare this step's snapshot with the previous step's
|
|
503
|
+
const prevStep = i > 0 ? findPrevStepSnapshot(this.events, i) : undefined;
|
|
504
|
+
if (prevStep !== undefined) {
|
|
505
|
+
const diff = summarizeSnapshotDiff(prevStep, event.snapshot);
|
|
506
|
+
if (diff)
|
|
507
|
+
prompt += `Page Change: ${diff}\n`;
|
|
508
|
+
}
|
|
367
509
|
prompt += `</step_${event.stepIndex + 1}>\n`;
|
|
368
510
|
}
|
|
369
511
|
else if (event.type === 'observation') {
|
|
@@ -496,6 +638,52 @@ IMPORTANT: Respond in Chinese.`,
|
|
|
496
638
|
return { found: false, analysis: `LLM invoke error: ${e.message}` };
|
|
497
639
|
}
|
|
498
640
|
}
|
|
641
|
+
/** Visual assertion: check a visual condition without interacting with the page */
|
|
642
|
+
async visualAssertElement(screenshotBase64, assertion) {
|
|
643
|
+
const assertToolSchema = zod_1.z.object({
|
|
644
|
+
passed: zod_1.z.boolean().describe('Whether the assertion is true based on the screenshot'),
|
|
645
|
+
reasoning: zod_1.z.string().describe('Brief explanation of why the assertion passed or failed'),
|
|
646
|
+
});
|
|
647
|
+
const messages = [
|
|
648
|
+
{
|
|
649
|
+
role: 'system',
|
|
650
|
+
content: `You are a UI visual assertion checker. Given a screenshot and an assertion statement, determine whether the assertion is TRUE based on what you see.
|
|
651
|
+
|
|
652
|
+
Output JSON with:
|
|
653
|
+
- passed: true if the assertion holds, false otherwise
|
|
654
|
+
- reasoning: brief explanation of your judgment
|
|
655
|
+
|
|
656
|
+
IMPORTANT:
|
|
657
|
+
- Only judge what you can SEE in the screenshot — do not infer hidden states
|
|
658
|
+
- Be strict: if the visual evidence is ambiguous, return passed=false
|
|
659
|
+
- Respond in Chinese for reasoning`,
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
role: 'user',
|
|
663
|
+
content: [
|
|
664
|
+
{ type: 'text', text: `请判断以下断言是否为真:${assertion}\n\n返回 passed (布尔值) 和 reasoning (中文说明)。` },
|
|
665
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${screenshotBase64}`, detail: 'high' } },
|
|
666
|
+
],
|
|
667
|
+
},
|
|
668
|
+
];
|
|
669
|
+
try {
|
|
670
|
+
const result = await this.llm.invoke(messages, {
|
|
671
|
+
AssertResult: {
|
|
672
|
+
description: 'Return the visual assertion result',
|
|
673
|
+
inputSchema: assertToolSchema,
|
|
674
|
+
execute: async (args) => args,
|
|
675
|
+
},
|
|
676
|
+
}, this.abortController.signal, { toolChoiceName: 'AssertResult' });
|
|
677
|
+
const parsed = result.toolCall.args;
|
|
678
|
+
return {
|
|
679
|
+
passed: typeof parsed.passed === 'boolean' ? parsed.passed : false,
|
|
680
|
+
reasoning: parsed.reasoning || 'No reasoning provided',
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
catch (e) {
|
|
684
|
+
return { passed: false, reasoning: `LLM invoke error: ${e.message}` };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
499
687
|
/** Build final result */
|
|
500
688
|
buildResult(success, data, stepCount, finalSnapshot) {
|
|
501
689
|
const snapshot = finalSnapshot || '';
|